Compare commits

...

47 Commits
v0.2.0 ... main

Author SHA1 Message Date
Peter Steinberger
ae08789eea
chore(deps): update dependencies
Some checks failed
Tests / Integration Tests (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-15) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-06-23 21:23:19 +01:00
Peter Steinberger
a6c5cb90b1 fix(models): complete GPT-5 Chat registration
Some checks failed
Lint / SwiftLint (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-15) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-06-13 02:41:53 -04:00
Peter Steinberger
0326be775e
fix: harden buffered streams and reasoning boundaries (#24)
Some checks failed
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-15) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
2026-06-11 17:31:39 -07:00
Peter Steinberger
bdb948dbe1
fix: allow compatible reasoning model identity
Let Anthropic-compatible providers override the reasoning model id used in signed thinking metadata.

Test: swift test --filter 'Anthropic-compatible provider tags native thinking with wrapper identity'
2026-06-11 14:23:55 -07:00
Peter Steinberger
fc46b14ebe
fix: align Claude refusal streaming aliases
Apply Claude Fable 5 and Opus 4.8 refusal-streaming detection to unqualified compatible provider aliases.

Test: swift test --filter 'Model Capabilities - Streaming Support'
2026-06-11 14:08:38 -07:00
Peter Steinberger
4669fe58f4
feat: add Claude Fable 5 support (#21)
* feat: add Claude Fable 5 support

* style: satisfy Tachikoma CI lint

* ci: serialize Linux Swift tests
2026-06-11 13:05:47 -07:00
Peter Steinberger
9754b309dd
style: apply Swift formatting
Some checks failed
CI / Desktop Swift (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-15) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-06-09 00:50:15 +01:00
Peter Steinberger
9d94b2dbcf
fix(models): preserve GPT-5 Chat model identity 2026-06-09 00:50:11 +01:00
Peter Steinberger
aba9c59ac7
fix(build): refresh package resolution 2026-06-08 23:59:29 +01:00
Peter Steinberger
151a23faa7
fix(build): prefer sibling Commander checkout 2026-06-08 23:59:21 +01:00
Peter Steinberger
c447d51456
fix(models): preserve redirected Grok aliases 2026-06-08 23:53:58 +01:00
Peter Steinberger
eb0af5bbe5
fix(models): block unsupported Grok multi-agent routing 2026-06-08 23:33:04 +01:00
Peter Steinberger
50f59fee1b
fix(anthropic): omit temperature with thinking requests 2026-06-08 23:33:04 +01:00
Peter Steinberger
02f6ef7bf4
docs(models): document Grok multi-agent vision routing 2026-06-08 23:33:04 +01:00
Peter Steinberger
fab1908fde
fix(models): update Grok vision capabilities 2026-06-08 23:33:04 +01:00
Peter Steinberger
b5d94c0989
fix(models): update Grok pricing 2026-06-08 23:33:04 +01:00
Peter Steinberger
39cc174e94
fix(models): update Grok vision and Gemini pricing 2026-06-08 23:33:04 +01:00
Peter Steinberger
207f1ca133
fix(models): correct Gemini 3.5 Flash pricing 2026-06-08 23:33:04 +01:00
Peter Steinberger
cef9a426a2
fix(models): update latest provider catalog 2026-06-08 23:33:04 +01:00
Peter Steinberger
7b80238d4c
feat: support OpenAI chat-latest (#20)
Some checks failed
Lint / Package Validation (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-15) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
* feat: support OpenAI chat-latest

* style: format chat-latest support
2026-06-06 18:12:42 -07:00
Peter Steinberger
23fb16ac0f
docs: position README banner
Some checks failed
CI / Desktop Swift (${{ matrix.os }}) (macos-15) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-15) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-28 20:48:17 +01:00
Vincent Koc
de1126d007
ci: pin macOS runner labels 2026-05-28 20:53:45 +02:00
Peter Steinberger
cf5ee6d792
docs: add README banner 2026-05-28 19:43:32 +01:00
David Shrader
8b808f24c8
fix(config): annotate loadFromEnvironment @inline(never) to work around Swift release-mode inliner bug (#18)
Some checks failed
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
* fix(config): annotate loadFromEnvironment @inline(never) to work around Swift release-mode inliner bug

Closes #17.

In Swift 6 release builds, inlining `loadFromEnvironment` into the
singleton-init path causes the `_baseURLs[.anthropic]` write to be
incorrectly eliminated by the optimizer, 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.
The annotation prevents the inlining and the bug disappears.

Empirically reproduced via openclaw/Peekaboo release builds:
- 4/4 runs unpatched: ANTHROPIC_BASE_URL=http://127.0.0.1:1 ignored;
  requests landed at api.anthropic.com (real Claude responses)
- 3/3 runs with @inline(never): env honored, request fails with
  "Could not connect" as expected

Bug does NOT reproduce in:
- Debug builds (less aggressive inlining)
- swift test (runs in debug mode by default)
- Same release binary for OpenAI / Ollama / Azure
  (only Anthropic affected)
- Release builds where any observable side effect (fputs, etc.) is
  added to the loadFromEnvironment loop body (defeats elimination)

Adds the ANTHROPIC equivalent of the existing
`TachikomaConfiguration picks up base URLs from environment` test
(which currently covers OPENAI). Note: the test passes in debug
mode regardless of the annotation, so it cannot catch a regression
of removing @inline(never); covering the inliner regression would
need a release-mode integration test, which is out of scope for
the swift-testing harness. The annotation's doc comment is the
durable guard against accidental removal.

Investigation history (for the curious): initial fix was a
construction-time env read in AnthropicProvider.init. That worked
empirically but didn't address the root cause. Diagnostic
instrumentation in loadFromEnvironment hid the bug, which suggested
optimization-dependent behavior. Adding @inline(never) without any
diagnostic prints reproduced the fix, identifying the inliner as
the trigger.

* docs: update changelog for Anthropic base URL fix

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-26 00:29:10 +01:00
Peter Steinberger
63601973c6
feat: add MiniMax China provider (#19)
Some checks are pending
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Waiting to run
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Blocked by required conditions
CI / SwiftLint (push) Waiting to run
Cross-Platform CI / test-macos-14 (push) Waiting to run
Cross-Platform CI / test-macos-15 (push) Waiting to run
Cross-Platform CI / Ubuntu 22.04 LTS (push) Waiting to run
Cross-Platform CI / Ubuntu 24.04 LTS (push) Waiting to run
Cross-Platform CI / build-release (push) Blocked by required conditions
Lint / SwiftLint (push) Waiting to run
Lint / SwiftFormat (push) Waiting to run
Lint / Swift 6 Compatibility Check (push) Waiting to run
Lint / Package Validation (push) Waiting to run
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Waiting to run
Tests / Test Linux (6.2.1) (push) Waiting to run
Tests / Validate Swift Package (push) Waiting to run
Tests / Integration Tests (push) Waiting to run
2026-05-25 10:07:45 +01:00
Peter Steinberger
725bddca15
test: satisfy Google tool parameter lint
Some checks failed
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-23 16:31:34 +01:00
Brandon Charleson
4401117071
fix(google): drop orphan required entries before sending to Gemini (#15)
* fix(google): drop orphan `required` entries before sending to Gemini

Gemini's `GenerateContentRequest` validator rejects tool schemas where
`required` contains a property name that is not declared in `properties`
with HTTP 400 (`property is not defined`). The agent-side converter that
feeds AgentTool into the Google provider occasionally drops a property
during MCP→Agent translation (e.g. an `anyOf` union that loses its
top-level `type`), leaving the orphaned name in `required` and blowing
up every Gemini request.

Filter `tool.parameters.required` against the actual property keys
before serialising, and emit a one-line stderr warning when an entry
is dropped so the offending tool can be fixed upstream. OpenAI and
Anthropic accept the invalid schema silently, so this guard rail only
takes effect on strict validators.

Repro (with PeekabooAgentRuntime's `set_value` tool prior to the
companion Peekaboo fix):
  peekaboo agent --model gemini-3-flash "use list_apps once"
  → HTTP 400 ... `function_declarations[7].parameters.required[1]:
     property is not defined`

After this patch the request succeeds and stderr carries:
  [Tachikoma/Google] Tool 'set_value' lists required parameters that
  are not in `properties`: ["value"]. Dropping them from the Gemini
  request to keep the schema valid.

* fix: harden Gemini tool schema sanitization

* docs: add Gemini tool schema changelog

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-05-23 16:10:13 +01:00
Peter Steinberger
5eafbe963a
fix(auth): stabilize environment auth resolution
Some checks failed
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-22 13:05:09 +01:00
Peter Steinberger
ef97ba54a3
feat(auth): add OpenRouter credential support
Some checks failed
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-20 03:27:53 +01:00
Peter Steinberger
d8a774c6ae
fix(auth): harden absolute profile paths 2026-05-20 02:45:49 +01:00
Peter Steinberger
18f1ae6736
fix(auth): support absolute profile directories 2026-05-20 02:41:03 +01:00
Peter Steinberger
6afec5b955
test: serialize audio env fallback checks
Some checks failed
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-15 04:58:12 +01:00
Peter Steinberger
581cb9f765
style: apply SwiftFormat 2026-05-15 04:52:53 +01:00
Peter Steinberger
7704018a47
feat(tachikoma): add MiniMax provider and local model parsing
Some checks are pending
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Waiting to run
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Blocked by required conditions
CI / SwiftLint (push) Waiting to run
Cross-Platform CI / test-macos-14 (push) Waiting to run
Cross-Platform CI / test-macos-15 (push) Waiting to run
Cross-Platform CI / Ubuntu 22.04 LTS (push) Waiting to run
Cross-Platform CI / Ubuntu 24.04 LTS (push) Waiting to run
Cross-Platform CI / build-release (push) Blocked by required conditions
Lint / SwiftLint (push) Waiting to run
Lint / SwiftFormat (push) Waiting to run
Lint / Swift 6 Compatibility Check (push) Waiting to run
Lint / Package Validation (push) Waiting to run
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Waiting to run
Tests / Test Linux (6.2.1) (push) Waiting to run
Tests / Validate Swift Package (push) Waiting to run
Tests / Integration Tests (push) Waiting to run
2026-05-14 21:36:52 +01:00
Peter Steinberger
b55bad7201
fix: preserve custom Ollama model parsing
Some checks failed
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-11 13:00:00 +01:00
Peter Steinberger
2a674fd0bd
test(providers): harden live provider integration tests
Some checks are pending
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Waiting to run
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Blocked by required conditions
CI / SwiftLint (push) Waiting to run
Cross-Platform CI / test-macos-14 (push) Waiting to run
Cross-Platform CI / test-macos-15 (push) Waiting to run
Cross-Platform CI / Ubuntu 22.04 LTS (push) Waiting to run
Cross-Platform CI / Ubuntu 24.04 LTS (push) Waiting to run
Cross-Platform CI / build-release (push) Blocked by required conditions
Lint / SwiftLint (push) Waiting to run
Lint / SwiftFormat (push) Waiting to run
Lint / Swift 6 Compatibility Check (push) Waiting to run
Lint / Package Validation (push) Waiting to run
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Waiting to run
Tests / Test Linux (6.2.1) (push) Waiting to run
Tests / Validate Swift Package (push) Waiting to run
Tests / Integration Tests (push) Waiting to run
2026-05-10 18:54:25 +01:00
Peter Steinberger
db54bba8ce
feat(models): refresh provider catalog 2026-05-10 14:43:43 +01:00
Peter Steinberger
3a3fad60cd
docs: remove stale O3 model references 2026-05-10 09:46:03 +01:00
Peter Steinberger
65d386c19e
feat: add GPT-5.5 and Claude Opus 4.7 models 2026-05-10 09:43:33 +01:00
Peter Steinberger
3765b08186
fix: prefer local commander dependency when embedded
Some checks are pending
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Waiting to run
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Blocked by required conditions
CI / SwiftLint (push) Waiting to run
Cross-Platform CI / test-macos-14 (push) Waiting to run
Cross-Platform CI / test-macos-15 (push) Waiting to run
Cross-Platform CI / Ubuntu 22.04 LTS (push) Waiting to run
Cross-Platform CI / Ubuntu 24.04 LTS (push) Waiting to run
Cross-Platform CI / build-release (push) Blocked by required conditions
Lint / SwiftLint (push) Waiting to run
Lint / SwiftFormat (push) Waiting to run
Lint / Swift 6 Compatibility Check (push) Waiting to run
Lint / Package Validation (push) Waiting to run
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Waiting to run
Tests / Test Linux (6.2.1) (push) Waiting to run
Tests / Validate Swift Package (push) Waiting to run
Tests / Integration Tests (push) Waiting to run
2026-05-09 05:51:50 +01:00
Peter Steinberger
4e7d56f7a8
docs: thank tachikoma contributors
Some checks failed
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
Cross-Platform CI / test-macos-14 (push) Has been cancelled
Cross-Platform CI / test-macos-15 (push) Has been cancelled
Cross-Platform CI / Ubuntu 22.04 LTS (push) Has been cancelled
Cross-Platform CI / Ubuntu 24.04 LTS (push) Has been cancelled
Lint / Swift 6 Compatibility Check (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Has been cancelled
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Has been cancelled
Lint / SwiftLint (push) Has been cancelled
Lint / SwiftFormat (push) Has been cancelled
Lint / Package Validation (push) Has been cancelled
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Has been cancelled
Tests / Test Linux (6.2.1) (push) Has been cancelled
Tests / Validate Swift Package (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Has been cancelled
Cross-Platform CI / build-release (push) Has been cancelled
2026-05-06 00:50:35 +01:00
Peter Steinberger
6b289d2e8f
fix: triage contributor provider fixes 2026-05-06 00:49:50 +01:00
Peter Steinberger
f3f0dc0328
test: use home env for credential fixture 2026-05-06 00:01:55 +01:00
Peter Steinberger
6b83f3cb7c
fix: import FoundationNetworking for compatible provider 2026-05-05 23:56:16 +01:00
Peter Steinberger
c87fe4c46a
fix: forward custom provider proxy headers 2026-05-05 23:51:54 +01:00
Peter Steinberger
52d2fb86f2
fix(openai): resolve oauth for responses provider
Some checks are pending
CI / Desktop Swift (${{ matrix.os }}) (macos-latest) (push) Waiting to run
CI / Desktop Swift (${{ matrix.os }}) (ubuntu-22.04) (push) Waiting to run
CI / Build Examples on ${{ matrix.os }} (macos-latest) (push) Waiting to run
CI / Apple Platforms (${{ matrix.platform }}) (iOS Simulator) (push) Blocked by required conditions
CI / SwiftLint (push) Waiting to run
Cross-Platform CI / test-macos-14 (push) Waiting to run
Cross-Platform CI / test-macos-15 (push) Waiting to run
Cross-Platform CI / Ubuntu 22.04 LTS (push) Waiting to run
Cross-Platform CI / Ubuntu 24.04 LTS (push) Waiting to run
Cross-Platform CI / build-release (push) Blocked by required conditions
Lint / SwiftLint (push) Waiting to run
Lint / SwiftFormat (push) Waiting to run
Lint / Swift 6 Compatibility Check (push) Waiting to run
Lint / Package Validation (push) Waiting to run
Tests / Test Apple Platforms (platform=macOS, macOS, macosx) (push) Waiting to run
Tests / Test Linux (6.2.1) (push) Waiting to run
Tests / Validate Swift Package (push) Waiting to run
Tests / Integration Tests (push) Waiting to run
2026-05-04 09:47:40 +01:00
Peter Steinberger
8b93106924
style: satisfy swiftformat 2026-05-03 16:27:52 +01:00
106 changed files with 10258 additions and 1712 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,32 @@
All notable changes to the Tachikoma project will be documented in this file.
## [Unreleased]
### Added
- Added first-class OpenAI `chat-latest` support with parsing aliases, Responses API routing, model capabilities, and usage estimates.
- Added first-class MiniMax support with the `MiniMax-M2.7` catalog models, `MINIMAX_API_KEY` / `MINIMAX_BASE_URL` configuration, bearer-token Anthropic-compatible transport, model parsing shortcuts, usage estimates, and provider tests.
- Added explicit LM Studio model shortcuts such as `lmstudio` and `lmstudio/openai/gpt-oss-120b` so local provider selections no longer fall through to Ollama custom IDs.
### Changed
- Refreshed the first-class model catalog to current provider IDs: OpenAI GPT-5.5/5.4, Claude Fable 5/Opus 4.8/Opus 4.7/Sonnet 4.6/Haiku 4.5, Gemini 3.1, Mistral latest aliases, Groq current production IDs, and xAI Grok 4.3/4.20.
- Added explicit `claude-fable-5` support with 1M context, 128K max output, signed-thinking replay, refusal handling, and non-streaming generation; `LanguageModel.default` remains `claude-opus-4-8`, while `LanguageModel.defaultStreaming` now uses streaming-safe `gpt-5.5`.
- Removed stale direct model support for retired or non-canonical IDs including GPT-5.1/5.2/pseudo-thinking models, deprecated Claude Sonnet/Opus 4 snapshots, Grok 2/3/4-fast rows, old Groq Llama/Mixtral/Gemma aliases, stale Mistral aliases, and invalid LM Studio `current`.
### Fixed
- OpenAI `gpt-5-chat-latest` now preserves its distinct model identity, appears in model listings, and applies GPT-5 parameter filtering instead of being rewritten to `chat-latest`.
- SwiftPM consumers now resolve Commander from the package URL instead of accidentally inheriting a sibling local checkout.
- Ollama model parsing now preserves explicit custom vision model IDs such as `qwen2.5vl:3b` instead of falling back to `llama3.3` (#16).
- Auth resolution now snapshots environment-ignore state consistently, preventing parallel tests and concurrent callers from falling back to stored OpenRouter credentials when an environment override is present.
- SwiftPM consumers can now resolve Commander remotely instead of requiring a local `../Commander` checkout. Thanks @malpern.
- Custom OpenAI-compatible and Anthropic-compatible providers now honor per-provider `options.apiKey` values from profile config. Thanks @381181295.
- Google/Gemini request encoding now sends tool results as documented user `functionResponse` turns and merges consecutive same-role contents before calling the API. Thanks @hsrvc.
- Google/Gemini tool schemas now drop orphan `required` entries before request encoding so Gemini accepts simplified MCP tool definitions. Thanks @bcharleson.
- OpenAI Responses API providers now resolve shared OAuth/API-key credentials instead of requiring `OPENAI_API_KEY` directly.
- Credential loading now only maps exact API-key credential names to provider API keys, so OAuth access/refresh tokens no longer overwrite configured OpenAI or Anthropic keys.
- Custom OpenAI-compatible and Anthropic-compatible providers now forward configured proxy headers to request calls.
- Anthropic base URL environment overrides now survive Swift release builds, so `ANTHROPIC_BASE_URL` can route requests through local proxies. Thanks @shraderdm.
## [0.2.0] - 2026-04-28
### Added
@ -9,10 +35,10 @@ All notable changes to the Tachikoma project will be documented in this file.
- Azure provider unit tests using URLProtocol stubs to verify path, query, and auth header construction.
### Changed
- Added OpenAI's GPT-5.1 family (flagship/mini/nano) throughout the model enums, selectors, provider factories, capability registry, pricing tables, docs, and test suites. GPT aliases (`gpt`, `gpt-5`, `gpt-4o`) now normalize to `.openai(.gpt51)` so downstream apps inherit the new default seamlessly.
- Added OpenAI's GPT-5.1 family (flagship/mini/nano) throughout the model enums, selectors, provider factories, capability registry, pricing tables, docs, and test suites. GPT aliases (`gpt`, `gpt-5`) now normalize to supported GPT-5 models so downstream apps inherit the new default seamlessly.
- Expanded xAI Grok support to the full November 2025 catalog (`grok-4-fast-*`, `grok-code-fast-1`, `grok-2-*`, `grok-vision-beta`, etc.), updated the CLI shortcuts so `grok` now maps to `grok-4-fast-reasoning`, and refreshed selectors, provider parsers, capability tables, and docs snippets to match the official API lineup.
- Google/Gemini support now targets the Gemini 2.5 family exclusively (`gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`), with updated model selectors, parsers, docs, and pricing tables; older 1.5/2.0 IDs are no longer recognized.
- Removed deprecated OpenAI reasoning models (`o1`, `o1-mini`, `o3`, `o3-mini`) in favour of the GPT5 family plus `o4-mini`, updating enums, provider factories, capability tables, prompts, and documentation metadata accordingly.
- Removed deprecated OpenAI reasoning models (`o1`, `o1-mini`, `o3`, `o3-mini`, `o4-mini`) in favour of the 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.
@ -61,8 +87,8 @@ All notable changes to the Tachikoma project will be documented in this file.
#### Provider Support
- **OpenAI Provider**: Complete integration with dual API support
- Chat Completions API for standard models (GPT-4o, GPT-4.1)
- Responses API for reasoning models (o3, o4 series)
- Chat Completions API for standard custom models
- Responses API for GPT-5 models
- Automatic API selection based on model capabilities
- Parameter filtering for reasoning models
- Full streaming support for both APIs
@ -70,7 +96,7 @@ All notable changes to the Tachikoma project will be documented in this file.
- **Anthropic Provider**: Native Claude API integration
- Support for Claude 4 (Opus, Sonnet) with thinking modes
- Claude 3.5/3.7 series compatibility
- Claude 4.x series compatibility
- Content block handling for multimodal inputs
- System prompt separation
- Server-Sent Events streaming

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-4o "Write a short story"
ai-cli --stream --model gpt-5.5 "Write a short story"
```
## Parameters
@ -35,7 +35,7 @@ ai-cli --stream --model gpt-4o "Write a short story"
| `-m, --model <MODEL>` | Specify the AI model to use |
| `--api <chat\|responses>` | For OpenAI models: select API type (default: responses for GPT-5) |
| `-s, --stream` | Stream the response in real-time |
| `--thinking` | Show reasoning process (O3, O4, GPT-5 - note: API currently doesn't expose actual reasoning) |
| `--thinking` | Show GPT-5 reasoning process (note: API currently doesn't expose actual reasoning) |
| `--verbose, -v` | Show detailed debug output |
| `--config` | Show current configuration and API key status |
| `--help, -h` | Show help message |
@ -60,30 +60,25 @@ Add to your shell profile (`~/.zshrc`, `~/.bashrc`) for persistence.
## Supported Models
### OpenAI
- **GPT-5 Series**: `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
- **O-Series**: `o3`, `o3-mini`, `o3-pro`, `o4-mini`
- **GPT-4**: `gpt-4.1`, `gpt-4.1-mini`, `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`
- **Legacy**: `gpt-3.5-turbo`
- **GPT-5 Series**: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
### Anthropic
- **Claude 4**: `claude-opus-4-1-20250805`, `claude-sonnet-4-20250514`
- **Claude 3.7**: `claude-3-7-sonnet`
- **Claude 3.5**: `claude-3-5-opus`, `claude-3-5-sonnet`, `claude-3-5-haiku`
- **Claude 4.x**: `claude-opus-4-7`, `claude-opus-4-5`, `claude-opus-4-1-20250805`, `claude-sonnet-4-6`, `claude-sonnet-4-5-20250929`, `claude-haiku-4-5`
### Google
- **Gemini 2.5**: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
- **Gemini**: `gemini-3.1-pro-preview`, `gemini-3.1-flash-lite`, `gemini-3-flash-preview`, `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
### Others
- **Mistral**: `mistral-large-2`, `mistral-small`, `codestral`
- **Groq**: `llama-3.1-70b`, `llama-3.1-8b`, `mixtral-8x7b`
* **Grok**: `grok-4-0709`, `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`, `grok-code-fast-1`, `grok-3`, `grok-3-mini`, `grok-2-1212`, `grok-2-vision-1212`, `grok-2-image-1212`
- **Ollama** (local): `llama3.3`, `llava`, `codellama`, any installed model
- **Mistral**: `mistral-large-latest`, `mistral-medium-latest`, `mistral-medium-3-5`, `mistral-small-latest`, `open-mistral-nemo-2407`, `codestral-latest`
- **Groq**: `openai/gpt-oss-120b`, `openai/gpt-oss-20b`, `llama-3.3-70b-versatile`, `llama-3.1-8b-instant`
- **Grok**: `grok-4.3`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`
- **Ollama** (local): `llama3.3`, `llava`, any installed model
### Model Shortcuts
- `claude` → claude-opus-4-1-20250805
- `gpt` → gpt-4.1
- `gemini` → gemini-2.5-flash
- `grok` → grok-4-fast-reasoning
- `claude` → claude-opus-4-7
- `gpt` → gpt-5.5
- `gemini` → gemini-3.1-pro-preview
- `grok` → grok-4.3
- `llama` → llama3.3
## Examples
@ -98,7 +93,7 @@ ai-cli --stream --model gpt-5 "Explain the theory of relativity"
# API selection for OpenAI
ai-cli --model gpt-5 --api chat "Use Chat Completions API"
ai-cli --model o3 --api responses "Use Responses API"
ai-cli --model gpt-5.5 --api responses "Use Responses API"
# Debug mode
ai-cli --verbose --model opus "Debug this request"

View File

@ -58,7 +58,7 @@ struct AICLI {
if let modelString = config.modelString {
model = try ModelSelector.parseModel(modelString)
} else {
model = .openai(.gpt51) // Default to GPT-5.1
model = .openai(.gpt55)
}
} catch {
print("❌ Error parsing model: \(error)")
@ -190,7 +190,7 @@ struct AICLI {
-m, --model <MODEL> Specify the AI model to use
--api <API> For OpenAI models: 'chat' or 'responses' (default: responses for GPT-5)
-s, --stream Stream the response (partial support)
--thinking Show reasoning/thinking process (O3, O4, GPT-5 via Responses API)
--thinking Show reasoning/thinking process (GPT-5 via Responses API)
--verbose, -v Show detailed debug output
--config Show current configuration and exit
-h, --help Show this help message
@ -202,7 +202,7 @@ struct AICLI {
# Use specific models
ai-cli --model claude "Explain quantum computing"
ai-cli --model gpt-4o "Describe this image"
ai-cli --model gpt-5.5 "Describe this image"
ai-cli --model grok "Tell me a joke"
ai-cli --model llama3.3 "Help me debug this code"
@ -213,53 +213,51 @@ struct AICLI {
# Streaming responses
ai-cli --stream --model claude "Write a short story"
# Show thinking process (reasoning models)
ai-cli --thinking --model gpt-5-thinking "Solve this logic puzzle"
# Show thinking process
ai-cli --thinking --model gpt-5.5 "Solve this logic puzzle"
ai-cli --thinking --model gpt-5 "Complex reasoning task"
PROVIDERS & MODELS:
OpenAI:
gpt-5, gpt-5-pro, gpt-5-mini, gpt-5-nano (GPT-5 series, August 2025)
gpt-5-thinking, gpt-5-thinking-mini, gpt-5-thinking-nano
gpt-4.1, gpt-4.1-mini, o4-mini (GPT-4.1 / reasoning)
gpt-4o, gpt-4o-mini (Multimodal)
gpt-4-turbo (Legacy)
gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.4-nano
Anthropic:
claude-opus-4-1-20250805, claude-sonnet-4-20250514 (Claude 4)
claude-3-7-sonnet (Claude 3.7)
claude-3-5-opus, claude-3-5-sonnet, claude-3-5-haiku (Claude 3.5)
claude-opus-4-7, claude-opus-4-5, claude-opus-4-1-20250805
claude-sonnet-4-6, claude-sonnet-4-5-20250929, claude-haiku-4-5
Google:
gemini-3.1-pro-preview, gemini-3.1-flash-lite
gemini-3-flash-preview
gemini-2.5-pro (reasoning, thinking support)
gemini-2.5-flash, gemini-2.5-flash-lite
Mistral:
mistral-large-2, mistral-large, mistral-small
mistral-nemo, codestral
mistral-large-latest, mistral-medium-latest, mistral-medium-3-5
mistral-small-latest, open-mistral-nemo-2407, codestral-latest
Groq (Ultra-fast):
llama-3.1-70b, llama-3.1-8b
mixtral-8x7b, gemma2-9b
openai/gpt-oss-120b, openai/gpt-oss-20b
llama-3.3-70b-versatile, llama-3.1-8b-instant
meta-llama/llama-4-maverick-17b-128e-instruct
Grok (xAI):
grok-4-0709, grok-4-fast-reasoning, grok-4-fast-non-reasoning
grok-code-fast-1, grok-3, grok-3-mini
grok-2-1212, grok-2-vision-1212, grok-2-image-1212 (Vision)
grok-4.3
grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning
Ollama (Local):
llama3.3, llama3.2, llama3.1 (Recommended)
llava, bakllava (Vision models)
codellama, mistral-nemo, qwen2.5
deepseek-r1, command-r-plus
mistral-nemo, qwen2.5
deepseek-r1:8b, deepseek-r1:671b, command-r-plus
Custom: any-model:tag
SHORTCUTS:
claude, opus claude-opus-4-1-20250805
gpt, gpt4 gpt-4.1
grok grok-4-fast-reasoning
gemini gemini-2.5-flash
claude, opus claude-opus-4-7
gpt gpt-5.5
grok grok-4.3
gemini gemini-3.1-pro-preview
llama, llama3 llama3.3
API KEYS:
@ -376,6 +374,8 @@ struct AICLI {
case .openai: .openai
case .anthropic: .anthropic
case .google: .google
case .minimax: .minimax
case .minimaxCN: .minimaxCN
case .mistral: .mistral
case .groq: .groq
case .grok: .grok
@ -406,6 +406,14 @@ struct AICLI {
print("# Legacy names still supported:")
print("export GOOGLE_API_KEY='gk-your-key-here'")
print("Get your key at: https://aistudio.google.com/apikey")
case .minimax:
print("Set your MiniMax API key:")
print("export MINIMAX_API_KEY='your-key-here'")
case .minimaxCN:
print("Set your MiniMax China API key:")
print("export MINIMAX_CN_API_KEY='your-key-here'")
print("# or reuse the global MiniMax key name:")
print("export MINIMAX_API_KEY='your-key-here'")
case .mistral:
print("Set your Mistral API key:")
print("export MISTRAL_API_KEY='your-key-here'")
@ -473,7 +481,7 @@ struct AICLI {
let supportsThinking = self.isReasoningModel(model) && actualApiMode != .chat
if config.showThinking, !supportsThinking {
print("⚠️ Note: --thinking only works with O3, O4, and GPT-5 models via Responses API")
print("⚠️ Note: --thinking only works with GPT-5 models via Responses API")
}
if case let .openai(openaiModel) = model, actualApiMode == .chat {
@ -585,15 +593,15 @@ struct AICLI {
static func isReasoningModel(_ model: LanguageModel) -> Bool {
guard case let .openai(openaiModel) = model else { return false }
switch openaiModel {
case .o4Mini,
.gpt5,
case .gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano,
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
.gpt5Mini:
return true
case .gpt5,
.gpt5Nano:
return true
default:
return false
@ -672,7 +680,7 @@ struct AICLI {
}
}
// If no summary, try content array (for O3/O4)
// If no summary, try content array.
if reasoningText == nil || reasoningText?.isEmpty == true {
if let contentArray = output["content"] as? [[String: Any]] {
let reasoningParts = contentArray.compactMap { item -> String? in
@ -801,23 +809,19 @@ struct AICLI {
switch model {
case let .openai(openaiModel):
switch openaiModel {
case .gpt5: return nil // Pricing TBD
case .gpt5Mini: return nil // Pricing TBD
case .gpt5Nano: return nil // Pricing TBD
case .gpt4o:
case .gpt55, .gpt54, .gpt5:
inputCostPer1k = 0.005
outputCostPer1k = 0.015
case .gpt4oMini:
inputCostPer1k = 0.000_15
outputCostPer1k = 0.0006
outputCostPer1k = 0.020
case .gpt54Mini, .gpt54Nano, .gpt5Mini, .gpt5Nano:
return nil // Pricing TBD
default: return nil
}
case let .anthropic(anthropicModel):
switch anthropicModel {
case .opus4, .opus4Thinking:
case .opus47, .opus45, .opus4:
inputCostPer1k = 0.015
outputCostPer1k = 0.075
case .sonnet4, .sonnet4Thinking:
case .sonnet46, .sonnet45:
inputCostPer1k = 0.003
outputCostPer1k = 0.015
case .haiku45:
@ -825,6 +829,9 @@ struct AICLI {
outputCostPer1k = 0.004
default: return nil
}
case .minimax:
inputCostPer1k = 0.0003
outputCostPer1k = 0.0012
default:
return nil
}

View File

@ -56,18 +56,18 @@ extension TachikomaExamples {
print("Creating provider-specific models with type safety:")
// OpenAI models
let gpt4o = Model.openai(.gpt4o)
let gpt41 = Model.openai(.gpt41)
let gpt55 = Model.openai(.gpt55)
let gpt54 = Model.openai(.gpt54)
let gpt5Mini = Model.openai(.gpt5Mini)
// Anthropic models
let opus4 = Model.anthropic(.opus4)
let sonnet4 = Model.anthropic(.sonnet4)
let sonnet46 = Model.anthropic(.sonnet46)
let haiku45 = Model.anthropic(.haiku45)
// Grok models
let grok4 = Model.grok(.grok4FastReasoning)
let grok2Vision = Model.grok(.grok2Vision)
let grok43 = Model.grok(.grok43)
let grokReasoning = Model.grok(.grok420Reasoning)
// Ollama models
let llama33 = Model.ollama(.llama3_3)
@ -75,17 +75,17 @@ extension TachikomaExamples {
// Custom endpoints
let openRouter = Model.openRouter(modelId: "anthropic/claude-sonnet-4.5")
let customAPI = Model.openaiCompatible(modelId: "gpt-4", baseURL: "https://api.azure.com")
let customAPI = Model.openaiCompatible(modelId: "gpt-5.5", baseURL: "https://api.azure.com")
let models = [
gpt4o,
gpt41,
gpt55,
gpt54,
gpt5Mini,
opus4,
sonnet4,
sonnet46,
haiku45,
grok4,
grok2Vision,
grok43,
grokReasoning,
llama33,
llava,
openRouter,
@ -100,9 +100,9 @@ extension TachikomaExamples {
// Model capabilities
print("\nModel capabilities:")
print(" • Vision support: \(gpt4o.supportsVision)")
print(" • Vision support: \(gpt55.supportsVision)")
print(" • Tool support: \(opus4.supportsTools)")
print(" • Streaming support: \(sonnet4.supportsStreaming)")
print(" • Streaming support: \(sonnet46.supportsStreaming)")
}
}
@ -192,7 +192,7 @@ extension TachikomaExamples {
// Record some usage
try? await tracker.recordUsage(
operation: .generation,
model: "gpt-4o".lowercased(),
model: "gpt-5.5".lowercased(),
inputTokens: 100,
outputTokens: 50,
cost: 0.003,
@ -273,9 +273,9 @@ extension TachikomaExamples {
// Test different provider creations (these will fail without API keys, which is expected)
let models: [(String, Model)] = [
("OpenAI GPT-4o", .openai(.gpt4o)),
("OpenAI GPT-5.5", .openai(.gpt55)),
("Anthropic Opus 4", .anthropic(.opus4)),
("Grok 4 Fast", .grok(.grok4FastReasoning)),
("Grok 4.3", .grok(.grok43)),
("Ollama Llama 3.3", .ollama(.llama3_3)),
]
@ -319,7 +319,7 @@ extension String {
extension Model {
var supportsVision: Bool {
switch self {
case .openai(.gpt4o), .grok(.grok2Vision), .ollama(.llava):
case .openai(.gpt55), .ollama(.llava):
true
default:
false

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 (O3, GPT-5)
- **Thinking Display**: Show reasoning process for models that support it (GPT-5)
- **Interactive Mode**: Continuous conversation with history management
- **Multiple Output Formats**: Normal, JSON, or Markdown formatting
- **Tool Visualization**: See tool calls with timing and results
@ -50,7 +50,7 @@ In interactive mode:
```bash
agent-cli --model gpt-5 "Explain quantum computing"
agent-cli --model claude "Write a haiku"
agent-cli --model o3 --thinking "Solve this step by step: ..."
agent-cli --model gpt-5-thinking --thinking "Solve this step by step: ..."
```
### With MCP Tools
@ -171,13 +171,10 @@ agent-cli --mcp-server "db -- npx @modelcontextprotocol/server-postgres postgres
## Supported Models
### OpenAI
- GPT-5 series: `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
- O-series: `o3`, `o3-mini`, `o3-pro`, `o4-mini`
- GPT-4: `gpt-4.1`, `gpt-4o`, `gpt-4-turbo`
- GPT-5 series: `gpt-5.5`, `gpt-5.4`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`
### Anthropic
- Claude 4: `opus-4`, `sonnet-4`
- Claude 3.5: `claude`, `sonnet`, `haiku`
- Claude 4.x: `claude-opus-4.7`, `claude-sonnet-4.6`, `claude-haiku-4.5`
### Others
- Google: `gemini-2.5-pro`, `gemini-2.5-flash`, `gemini-2.5-flash-lite`
@ -209,7 +206,7 @@ agent-cli --mcp-server "db -- npx @modelcontextprotocol/server-sqlite ./app.db"
### Complex Reasoning
```bash
agent-cli --model o3 --thinking \
agent-cli --model gpt-5-thinking --thinking \
"Plan a distributed system architecture for a social media platform"
```

View File

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

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

View File

@ -49,7 +49,7 @@ echo " • Stream token handling"
echo ""
echo "🔑 API Integration Examples (require valid API keys):"
echo " • OpenAI GPT-4o, GPT-4.1, o3 generation"
echo " • OpenAI GPT-5 generation"
echo " • Anthropic Claude Opus 4, Sonnet 4 generation"
echo " • Grok 4 and Grok 2 Vision models"
echo " • Ollama local models (llama3.3, llava)"
@ -70,7 +70,7 @@ echo "🚀 How to Use Tachikoma:"
echo "========================"
echo ""
echo "1. Basic Generation:"
echo ' let answer = try await generate("What is 2+2?", using: .openai(.gpt4o))'
echo ' let answer = try await generate("What is 2+2?", using: .openai(.gpt55))'
echo ""
echo "2. With Tools:"
echo ' @ToolKit'
@ -88,4 +88,4 @@ echo ' let response = try await conversation.continue(using: .anthropic(.opus4
echo ""
echo "🕷️ Tachikoma - Intelligent • Adaptable • Reliable"
echo " All examples completed successfully!"
echo " All examples completed successfully!"

View File

@ -9,7 +9,7 @@ func demonstrateMultiChannelResponse() async throws {
print("=== Multi-Channel Response Demo ===\n")
let result = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [
.user("Explain how recursion works in programming"),
],
@ -36,7 +36,7 @@ func demonstrateReasoningEffort() async throws {
// High effort for complex problem
print("High effort response:")
let complexResult = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [
.user("Design a distributed system for real-time collaboration"),
],
@ -50,7 +50,7 @@ func demonstrateReasoningEffort() async throws {
// Low effort for simple query
print("\nLow effort response:")
let simpleResult = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [
.user("What is the capital of Japan?"),
],
@ -82,7 +82,7 @@ func demonstrateRetryHandler() async throws {
print("Attempting API call...")
// Simulate a call that might fail
return try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [.user("Hello")],
)
},
@ -144,7 +144,7 @@ func demonstrateEnhancedTools() async throws {
}
let result = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [
.user("What's 25 * 4 and what's the weather in Tokyo?"),
],
@ -278,7 +278,7 @@ func demonstrateIntegratedFeatures() async throws {
do {
let result = try await retryHandler.execute {
try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [
.system("You are a helpful assistant. Use channels to organize your response."),
.user("Analyze the benefits of functional programming"),

View File

@ -16,7 +16,7 @@ public struct RealtimeAPIDemo {
// 1. Create configuration
print("\n1⃣ Creating Configuration...")
let config = SessionConfiguration(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .nova,
instructions: "You are a helpful voice assistant",
inputAudioFormat: .pcm16,
@ -108,7 +108,7 @@ public struct RealtimeAPIDemo {
Example:
let conversation = try RealtimeConversation(configuration: config)
try await conversation.start(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .nova
)
@ -118,7 +118,7 @@ public struct RealtimeAPIDemo {
/// Create a sample configuration for testing
public static func createSampleConfiguration() -> SessionConfiguration {
SessionConfiguration.voiceConversation(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .nova,
)
}
@ -127,7 +127,7 @@ public struct RealtimeAPIDemo {
public static func validateTypes() -> Bool {
// Test configuration creation
let config = self.createSampleConfiguration()
guard config.model == "gpt-4o-realtime-preview" else { return false }
guard config.model == "gpt-realtime" else { return false }
// Test VAD configuration
let vad = RealtimeTurnDetection.serverVAD

View File

@ -49,7 +49,7 @@ class RealtimeVoiceAssistant {
// Start the conversation
self.conversation = try await startRealtimeConversation(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .nova,
instructions: """
You are a helpful voice assistant. Keep responses concise and natural.
@ -151,7 +151,7 @@ class RealtimeDemo {
// Server VAD automatically detects when user starts/stops speaking
let conversation = try await startRealtimeConversation(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .alloy,
instructions: "You are a voice assistant with server-side voice activity detection",
)
@ -171,7 +171,7 @@ class RealtimeDemo {
// Configure for both text and audio responses
let conversation = try await startRealtimeConversation(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .shimmer,
)
@ -222,7 +222,7 @@ class RealtimeDemo {
}
let conversation = try await startRealtimeConversation(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .echo,
instructions: "You are a smart home assistant. Use the available tools to control devices.",
tools: [smartHomeTool],
@ -315,7 +315,7 @@ struct RealtimeVoiceView: View {
private func setupConversation() async {
do {
conversation = try await startRealtimeConversation(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .nova,
)

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-4o-realtime-preview",
model: "gpt-realtime",
voice: .nova,
)
print(" ✅ Model: \(voiceConfig.model)")

View File

@ -24,7 +24,7 @@ class BasicVoiceAssistant: ObservableObject {
// Start conversation with voice
try await self.conversation?.start(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .nova,
instructions: "You are a helpful voice assistant. Keep responses concise.",
)
@ -307,7 +307,7 @@ class AudioStreamingExample {
func setupAudioStreaming(apiKey: String) async throws {
// Configure for audio streaming
var config = EnhancedSessionConfiguration(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .echo,
inputAudioFormat: .pcm16,
outputAudioFormat: .pcm16,
@ -371,7 +371,7 @@ class MultiTurnConversation {
func runConversation(apiKey: String) async throws {
// Configure for multi-turn dialogue
let config = EnhancedSessionConfiguration(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .fable,
instructions: """
You are a knowledgeable assistant engaged in a multi-turn conversation.
@ -547,7 +547,7 @@ class VoiceAssistantViewController: UIViewController {
self.conversation = try RealtimeConversation(configuration: config)
try await self.conversation?.start(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .shimmer,
)

View File

@ -29,7 +29,7 @@ class RealtimeVoiceAssistant {
// Start with voice configuration
try await conversation.start(
model: .gpt4oRealtime,
model: .custom("gpt-realtime"),
voice: .nova,
instructions: "You are a helpful, witty, and friendly AI assistant. Keep responses concise.",
)
@ -77,7 +77,7 @@ class RealtimeVoiceAssistant {
// Advanced configuration with all features
let config = SessionConfiguration(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .nova,
instructions: """
You are an expert AI assistant with deep knowledge across many domains.
@ -147,7 +147,7 @@ class RealtimeVoiceAssistant {
// Configuration with tools
let config = SessionConfiguration.withTools(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .nova,
tools: [
// Weather tool

View File

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

View File

@ -1,8 +1,18 @@
// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.
import Foundation
import PackageDescription
let packageDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
let localCommanderPath = packageDirectory
.deletingLastPathComponent()
.appendingPathComponent("Commander")
.appendingPathComponent("Package.swift")
let commanderDependency: Package.Dependency = FileManager.default.fileExists(atPath: localCommanderPath.path)
? .package(path: "../Commander")
: .package(url: "https://github.com/steipete/Commander.git", from: "0.2.2")
let package = Package(
name: "Tachikoma",
platforms: [
@ -49,7 +59,7 @@ let package = Package(
targets: ["TachikomaConfigCLI"]),
],
dependencies: [
.package(path: "../Commander"),
commanderDependency,
.package(url: "https://github.com/apple/swift-log.git", from: "1.6.4"),
.package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"),
.package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"),

View File

@ -10,6 +10,8 @@
<a href="https://github.com/steipete/Tachikoma/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/steipete/Tachikoma/ci.yml?branch=main&style=for-the-badge&label=tests" alt="CI Status"></a>
</p>
![Tachikoma banner](docs/assets/readme-banner.jpg)
Modern, Swift-native APIs for text, vision, tools, and realtime voice.
</div>
@ -39,7 +41,7 @@ print(text)
```swift
import Tachikoma
let stream = try await stream("Explain actors in Swift.", using: .openai(.gpt52))
let stream = try await stream("Explain actors in Swift.", using: .openai(.gpt54))
for try await delta in stream {
print(delta.content ?? "", terminator: "")
}
@ -64,7 +66,7 @@ import Tachikoma
let pngData: Data = /* ... */
let image = ImageInput(data: pngData, mimeType: "image/png")
let answer = try await analyze(image: image, prompt: "Whats in this image?", using: .openai(.gpt4o))
let answer = try await analyze(image: image, prompt: "Whats in this image?", using: .openai(.gpt55))
print(answer)
```
@ -87,7 +89,7 @@ let tool = createTool(
}
let result = try await generateText(
model: .openai(.gpt52),
model: .openai(.gpt54),
messages: [.user("Compute 123 + 456 using the add tool.")],
tools: [tool],
maxSteps: 3
@ -98,10 +100,10 @@ print(result.text)
## Models
Common picks:
- Anthropic: `claude-opus-4-5` (`LanguageModel.default`)
- OpenAI: `gpt-5.2` (flagship), `gpt-5` (coding/agents), `o4-mini` (reasoning), `gpt-4o` (vision)
- Google: `gemini-3-flash`
- Grok: `grok-4-fast-reasoning`
- Anthropic: `claude-opus-4-8` (`LanguageModel.default`, non-streaming), `claude-fable-5` (explicit opt-in)
- OpenAI: `gpt-5.5` (`LanguageModel.defaultStreaming`), `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-nano`, `gpt-5`
- Google: `gemini-3.1-pro-preview`, `gemini-3-flash`
- Grok: `grok-4.3`
- Local: `ollama/llama3.3`
Full catalog (including enum case names + provider notes): [`docs/models.md`](docs/models.md).

View File

@ -25,6 +25,7 @@ public enum TKProviderId: String, CaseIterable, Sendable {
case anthropic
case grok
case gemini
case openrouter
public static func normalize(_ value: String) -> TKProviderId? {
let lower = value.lowercased()
@ -38,6 +39,7 @@ public enum TKProviderId: String, CaseIterable, Sendable {
case .anthropic: "Anthropic"
case .grok: "Grok (xAI)"
case .gemini: "Gemini"
case .openrouter: "OpenRouter"
}
}
@ -55,6 +57,7 @@ public enum TKProviderId: String, CaseIterable, Sendable {
// swiftformat:enable indent
case .grok: ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"]
case .gemini: ["GEMINI_API_KEY"]
case .openrouter: ["OPENROUTER_API_KEY"]
}
}
@ -78,8 +81,7 @@ public struct TKCredentialStore {
public init() {}
private var baseDir: String {
let dir = TachikomaConfiguration.profileDirectoryName
return NSString(string: "~/" + dir).expandingTildeInPath
TachikomaConfiguration.profileDirectoryPath
}
private var credentialsPath: String {
@ -137,8 +139,8 @@ public final class TKAuthManager {
private init() {}
private func environmentValue(for key: String) -> String? {
guard !self.ignoreEnv else { return nil }
private func environmentValue(for key: String, ignoringEnvironment: Bool) -> String? {
guard !ignoringEnvironment else { return nil }
let value = key.withCString { keyPtr -> String? in
guard let cValue = getenv(keyPtr) else { return nil }
let string = String(cString: cValue)
@ -149,6 +151,14 @@ public final class TKAuthManager {
return nil
}
private func authState() -> (ignoreEnv: Bool, creds: [String: String]) {
self.lock.lock()
let ignoreEnv = self.ignoreEnv
let creds = self.ignoreStore ? [:] : self.store.load()
self.lock.unlock()
return (ignoreEnv, creds)
}
@discardableResult
public func setIgnoreEnvironment(_ value: Bool) -> Bool {
self.lock.lock()
@ -168,20 +178,17 @@ public final class TKAuthManager {
}
public func credentialValue(for key: String) -> String? {
self.lock.lock()
let creds = self.ignoreStore ? [:] : self.store.load()
self.lock.unlock()
if let env = self.environmentValue(for: key) { return env }
return creds[key]
let state = self.authState()
if let env = self.environmentValue(for: key, ignoringEnvironment: state.ignoreEnv) { return env }
return state.creds[key]
}
public func resolveAuth(for provider: TKProviderId) -> TKAuthValue? {
self.lock.lock()
let creds = self.ignoreStore ? [:] : self.store.load()
self.lock.unlock()
let state = self.authState()
let creds = state.creds
switch provider {
case .openai:
if let env = self.environmentValue(for: "OPENAI_API_KEY") {
if let env = self.environmentValue(for: "OPENAI_API_KEY", ignoringEnvironment: state.ignoreEnv) {
return .bearer(env, betaHeader: nil)
}
if let access = creds["OPENAI_ACCESS_TOKEN"], !access.isEmpty {
@ -191,7 +198,7 @@ public final class TKAuthManager {
return .apiKey(key)
}
case .anthropic:
if let env = self.environmentValue(for: "ANTHROPIC_API_KEY") {
if let env = self.environmentValue(for: "ANTHROPIC_API_KEY", ignoringEnvironment: state.ignoreEnv) {
return .apiKey(env)
}
if let access = creds["ANTHROPIC_ACCESS_TOKEN"], !access.isEmpty {
@ -204,7 +211,7 @@ public final class TKAuthManager {
case .grok:
let envOrder = ["X_AI_API_KEY", "XAI_API_KEY", "GROK_API_KEY"]
for k in envOrder {
if let env = self.environmentValue(for: k) {
if let env = self.environmentValue(for: k, ignoringEnvironment: state.ignoreEnv) {
return .bearer(env, betaHeader: nil)
}
}
@ -212,10 +219,17 @@ public final class TKAuthManager {
if let val = creds[k], !val.isEmpty { return .bearer(val, betaHeader: nil) }
}
case .gemini:
if let env = self.environmentValue(for: "GEMINI_API_KEY") {
if let env = self.environmentValue(for: "GEMINI_API_KEY", ignoringEnvironment: state.ignoreEnv) {
return .apiKey(env)
}
if let val = creds["GEMINI_API_KEY"], !val.isEmpty { return .apiKey(val) }
case .openrouter:
if let env = self.environmentValue(for: "OPENROUTER_API_KEY", ignoringEnvironment: state.ignoreEnv) {
return .bearer(env, betaHeader: nil)
}
if let val = creds["OPENROUTER_API_KEY"], !val.isEmpty {
return .bearer(val, betaHeader: nil)
}
}
return nil
}
@ -311,7 +325,7 @@ public final class TKAuthManager {
requiresStateInTokenExchange: true,
pkce: pkce,
)
case .grok, .gemini:
case .grok, .gemini, .openrouter:
OAuthConfig(
prefix: "",
authorize: "",
@ -575,7 +589,7 @@ struct TKProviderValidator {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
request.httpBody = try? JSONSerialization.data(withJSONObject: [
"model": "claude-3-haiku-20241022",
"model": "claude-haiku-4-5",
"max_tokens": 1,
"messages": [
["role": "user", "content": "ping"],
@ -594,6 +608,13 @@ struct TKProviderValidator {
var request = URLRequest(url: URL(string: url)!)
request.httpMethod = "GET"
return await HTTP.perform(request: request, timeoutSeconds: self.timeoutSeconds)
case .openrouter:
return await self.validateBearer(
url: "https://openrouter.ai/api/v1/key",
secret: secret,
header: "Authorization",
valuePrefix: "Bearer ",
)
}
}

View File

@ -1,18 +1,56 @@
import Foundation
struct AnthropicReasoningReplayTarget {
let provider: String
let modelId: String
let endpointIdentity: String?
let allowsLegacyUnknown: Bool
func matches(_ customData: [String: String]) -> Bool {
guard customData["tachikoma.reasoning.provider"] == self.provider else {
return false
}
guard customData["tachikoma.reasoning.model"] == self.modelId else {
return false
}
return customData["tachikoma.reasoning.base_url"] == self.endpointIdentity
}
}
enum AnthropicMessageConversion {
static func convertMessagesToAnthropic(
_ messages: [ModelMessage],
thinkingEnabled: Bool,
reasoningTarget: AnthropicReasoningReplayTarget? = nil,
) throws
-> (String?, [AnthropicMessage])
{
var systemMessage: String?
var anthropicMessages: [AnthropicMessage] = []
var pendingSignedThinking: (text: String, signature: String, type: String)?
var pendingThinkingBlocks: [(text: String, signature: String?, type: String)] = []
let thinkingSignatureKey = "anthropic.thinking.signature"
let thinkingTypeKey = "anthropic.thinking.type"
func appendThinkingBlocks(
_ pendingBlocks: [(text: String, signature: String?, type: String)],
to content: inout [AnthropicContent],
) {
for pending in pendingBlocks {
if pending.type == "redacted_thinking" {
content.append(.redactedThinking(.init(
type: "redacted_thinking",
data: pending.text,
)))
} else if let signature = pending.signature {
content.append(.thinking(.init(
type: "thinking",
thinking: pending.text,
signature: signature,
)))
}
}
}
for message in messages {
switch message.role {
case .system:
@ -48,29 +86,32 @@ enum AnthropicMessageConversion {
}.joined()
let signature = message.metadata?.customData?[thinkingSignatureKey]
let type = message.metadata?.customData?[thinkingTypeKey] ?? "thinking"
if let signature, !signature.isEmpty {
pendingSignedThinking = (text: text, signature: signature, type: type)
let customData = message.metadata?.customData ?? [:]
if
customData["tachikoma.reasoning.provider"] != nil ||
customData["tachikoma.reasoning.model"] != nil ||
customData["tachikoma.reasoning.base_url"] != nil ||
customData["anthropic.thinking.model"] != nil
{
guard reasoningTarget?.matches(customData) == true else {
continue
}
} else if reasoningTarget?.allowsLegacyUnknown != true {
continue
}
if type == "redacted_thinking" {
pendingThinkingBlocks.append((text: text, signature: nil, type: type))
} else if let signature, !signature.isEmpty {
pendingThinkingBlocks.append((text: text, signature: signature, type: type))
}
continue
}
var content: [AnthropicContent] = []
if thinkingEnabled, let pending = pendingSignedThinking {
if pending.type == "redacted_thinking" {
content.append(.redactedThinking(.init(
type: "redacted_thinking",
redactedThinking: pending.text,
signature: pending.signature,
)))
} else {
content.append(.thinking(.init(
type: "thinking",
thinking: pending.text,
signature: pending.signature,
)))
}
pendingSignedThinking = nil
if thinkingEnabled, !pendingThinkingBlocks.isEmpty {
appendThinkingBlocks(pendingThinkingBlocks, to: &content)
pendingThinkingBlocks.removeAll()
}
// Process each content part
@ -139,21 +180,12 @@ enum AnthropicMessageConversion {
}
}
if thinkingEnabled, let pending = pendingSignedThinking {
let thinkingContent: AnthropicContent = if pending.type == "redacted_thinking" {
.redactedThinking(.init(
type: "redacted_thinking",
redactedThinking: pending.text,
signature: pending.signature,
))
} else {
.thinking(.init(
type: "thinking",
thinking: pending.text,
signature: pending.signature,
))
if thinkingEnabled, !pendingThinkingBlocks.isEmpty {
var content: [AnthropicContent] = []
appendThinkingBlocks(pendingThinkingBlocks, to: &content)
if !content.isEmpty {
anthropicMessages.append(AnthropicMessage(role: "assistant", content: content))
}
anthropicMessages.append(AnthropicMessage(role: "assistant", content: [thinkingContent]))
}
return (systemMessage, anthropicMessages)

View File

@ -1,3 +1,12 @@
#if canImport(CryptoKit)
import CryptoKit
private typealias ReasoningEndpointHasher = CryptoKit.SHA256
#else
import Crypto
private typealias ReasoningEndpointHasher = Crypto.SHA256
#endif
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
@ -16,18 +25,46 @@ public final class AnthropicProvider: ModelProvider {
private let model: LanguageModel.Anthropic
private let auth: TKAuthValue
private let betaHeader: String
private let additionalHeaders: [String: String]
private let reasoningProvider: String
private let reasoningModelId: String
private let reasoningBaseURL: String?
private let urlSession: URLSession
private static let requiredBetaFlags: [String] = [
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
]
public init(model: LanguageModel.Anthropic, configuration: TachikomaConfiguration) throws {
public init(
model: LanguageModel.Anthropic,
configuration: TachikomaConfiguration,
additionalHeaders: [String: String] = [:],
authOverride: TKAuthValue? = nil,
reasoningProvider: String = "anthropic",
reasoningModelId: String? = nil,
reasoningBaseURL: String? = nil,
urlSession: URLSession = .shared,
) throws {
self.model = model
self.modelId = model.modelId
self.baseURL = configuration.getBaseURL(for: .anthropic) ?? "https://api.anthropic.com"
self.additionalHeaders = additionalHeaders
self.reasoningProvider = reasoningProvider
self.reasoningModelId = reasoningModelId ?? model.modelId
self.reasoningBaseURL = ReasoningEndpointIdentity.canonical(
reasoningBaseURL ?? (reasoningProvider == "anthropic" ? self.baseURL : nil),
)
self.urlSession = urlSession
if let key = configuration.getAPIKey(for: .anthropic) {
if let authOverride {
self.auth = authOverride
switch authOverride {
case let .apiKey(key):
self.apiKey = key
case let .bearer(token, _):
self.apiKey = token
}
} else if let key = configuration.getAPIKey(for: .anthropic) {
self.auth = .apiKey(key)
self.apiKey = key
} else if let auth = TKAuthManager.shared.resolveAuth(for: .anthropic) {
@ -42,16 +79,18 @@ public final class AnthropicProvider: ModelProvider {
throw TachikomaError.authenticationFailed("ANTHROPIC_API_KEY not found")
}
self.betaHeader = Self.mergedBetaHeader(configuration: configuration, auth: self.auth)
self.betaHeader = Self.mergedBetaHeader(configuration: configuration, auth: self.auth, model: model)
let isFable = Self.isFable(model: model)
let supportsSafeStreaming = !Self.hasStreamingRefusalRisk(model: model)
self.capabilities = ModelCapabilities(
supportsVision: model.supportsVision,
supportsTools: model.supportsTools,
supportsStreaming: true,
supportsStreaming: supportsSafeStreaming,
supportsAudioInput: model.supportsAudioInput,
supportsAudioOutput: model.supportsAudioOutput,
contextLength: model.contextLength,
maxOutputTokens: 4096,
contextLength: isFable ? 1_000_000 : model.contextLength,
maxOutputTokens: isFable ? 128_000 : model.maxOutputTokens,
)
}
@ -79,6 +118,16 @@ public final class AnthropicProvider: ModelProvider {
}
private static func mergedBetaHeader(configuration: TachikomaConfiguration, auth: TKAuthValue) -> String {
self.mergedBetaHeader(configuration: configuration, auth: auth, model: nil)
}
private static func mergedBetaHeader(
configuration: TachikomaConfiguration,
auth: TKAuthValue,
model: LanguageModel.Anthropic?,
)
-> String
{
var existing: String?
if case let .bearer(_, betaHeader) = auth {
existing = betaHeader
@ -88,19 +137,88 @@ public final class AnthropicProvider: ModelProvider {
existing = configuration.credentialValue(for: "ANTHROPIC_BETA_HEADER")
}
if let model, Self.isFable(model: model) {
return existing?
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined(separator: ",") ?? ""
}
return Self.mergedBetaHeader(existing: existing)
}
private func anthropicThinking(from mode: AnthropicOptions.ThinkingMode?) -> AnthropicThinking? {
private func anthropicThinking(
from mode: AnthropicOptions.ThinkingMode?,
model: LanguageModel.Anthropic,
)
-> AnthropicThinking?
{
guard let mode else { return nil }
switch mode {
case .disabled:
return nil
case .adaptive:
if Self.isFable(model: model) { return nil }
guard self.usesAdaptiveThinking(model: model) else { return nil }
return AnthropicThinking(type: "adaptive", budgetTokens: nil)
case let .enabled(budgetTokens):
if Self.isFable(model: model) {
return nil
}
if case .opus48 = model {
return AnthropicThinking(type: "adaptive", budgetTokens: nil)
}
if case .opus47 = model {
return AnthropicThinking(type: "adaptive", budgetTokens: nil)
}
return AnthropicThinking(type: "enabled", budgetTokens: budgetTokens)
}
}
private func anthropicOutputConfig(
from mode: AnthropicOptions.ThinkingMode?,
settings: GenerationSettings,
model: LanguageModel.Anthropic,
)
-> AnthropicOutputConfig?
{
guard self.supportsEffort(model: model) else { return nil }
if let effort = settings.reasoningEffort?.rawValue {
return AnthropicOutputConfig(effort: effort)
}
if case .disabled = mode { return nil }
let effort = self.usesAdaptiveThinking(model: model) ? self.adaptiveEffort(from: mode) : nil
return effort.map { AnthropicOutputConfig(effort: $0) }
}
private func usesAdaptiveThinking(model: LanguageModel.Anthropic) -> Bool {
if Self.isFable(model: model) { return true }
if case .opus48 = model { return true }
if case .opus47 = model { return true }
if case .sonnet46 = model { return true }
return false
}
private func supportsEffort(model: LanguageModel.Anthropic) -> Bool {
if Self.isFable(model: model) { return true }
switch model {
case .opus48, .opus47, .opus45, .sonnet46:
return true
default:
return false
}
}
private func adaptiveEffort(from mode: AnthropicOptions.ThinkingMode?) -> String? {
guard case let .enabled(budgetTokens) = mode else { return nil }
if budgetTokens <= 4096 { return ReasoningEffort.low.rawValue }
if budgetTokens <= 12000 { return ReasoningEffort.medium.rawValue }
return ReasoningEffort.high.rawValue
}
private func messagesEndpointURL() throws -> URL {
guard let baseURL = self.baseURL, let url = URL(string: baseURL) else {
throw TachikomaError.invalidConfiguration("Invalid Anthropic base URL: \(self.baseURL ?? "<nil>")")
@ -127,16 +245,49 @@ public final class AnthropicProvider: ModelProvider {
self.applyAuth(to: &urlRequest, secret: apiKey)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")
for (key, value) in self.additionalHeaders {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
let requestedThinking = self.anthropicThinking(from: request.settings.providerOptions.anthropic?.thinking)
let validatedSettings = request.settings.validated(for: .anthropic(self.model))
if
Self.isFable(model: self.model),
case .disabled = validatedSettings.providerOptions.anthropic?.thinking
{
throw TachikomaError.invalidConfiguration(
"Claude Fable 5 always uses adaptive thinking; disabled thinking is not supported",
)
}
if Self.isFable(model: self.model), request.messages.last?.role == .assistant {
throw TachikomaError.invalidConfiguration(
"Claude Fable 5 does not support assistant prefill requests",
)
}
let requestedThinking = self.anthropicThinking(
from: validatedSettings.providerOptions.anthropic?.thinking,
model: self.model,
)
let outputConfig = self.anthropicOutputConfig(
from: validatedSettings.providerOptions.anthropic?.thinking,
settings: validatedSettings,
model: self.model,
)
var thinking: AnthropicThinking?
let systemMessage: String?
let messages: [AnthropicMessage]
let preserveSignedThinking = requestedThinking != nil || self.requiresSignedThinkingReplay(model: self.model)
let reasoningTarget = AnthropicReasoningReplayTarget(
provider: self.reasoningProvider,
modelId: self.reasoningModelId,
endpointIdentity: self.reasoningBaseURL,
allowsLegacyUnknown: !Self.isFable(model: self.model),
)
do {
thinking = requestedThinking
(systemMessage, messages) = try AnthropicMessageConversion.convertMessagesToAnthropic(
request.messages,
thinkingEnabled: requestedThinking != nil,
thinkingEnabled: preserveSignedThinking,
reasoningTarget: reasoningTarget,
)
} catch {
// If we can't provide signed thinking blocks for a cached/history session, fall back to non-thinking mode.
@ -145,19 +296,26 @@ public final class AnthropicProvider: ModelProvider {
(systemMessage, messages) = try AnthropicMessageConversion.convertMessagesToAnthropic(
request.messages,
thinkingEnabled: false,
reasoningTarget: reasoningTarget,
)
} else {
throw error
}
}
let maxTokens = validatedSettings.maxTokens ?? self.defaultMaxTokens(for: self.model)
if !stream, Self.requiresExtendedNonStreamingTimeout(model: self.model, maxTokens: maxTokens) {
urlRequest.timeoutInterval = 1800
}
let anthropicRequest = try AnthropicMessageRequest(
model: modelId,
maxTokens: request.settings.maxTokens ?? 1024,
temperature: request.settings.temperature,
maxTokens: maxTokens,
temperature: thinking == nil ? validatedSettings.temperature : nil,
system: systemMessage,
messages: messages,
tools: request.tools?.map { try self.convertToolToAnthropic($0) },
thinking: thinking,
outputConfig: outputConfig,
stream: stream,
)
@ -187,7 +345,7 @@ public final class AnthropicProvider: ModelProvider {
}
}
let (data, response) = try await URLSession.shared.data(for: urlRequest)
let (data, response) = try await self.urlSession.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw TachikomaError.networkError(NSError(domain: "Invalid response", code: 0))
@ -219,7 +377,7 @@ public final class AnthropicProvider: ModelProvider {
switch content {
case let .text(textContent):
textContent.text
case .toolUse:
case .thinking, .redactedThinking, .toolUse:
nil
}
}.joined()
@ -229,19 +387,63 @@ public final class AnthropicProvider: ModelProvider {
outputTokens: anthropicResponse.usage.outputTokens,
)
let finishReason: FinishReason? = switch anthropicResponse.stopReason {
case "end_turn": .stop
case "max_tokens": .length
case "tool_use": .toolCalls
case "stop_sequence": .stop
default: .other
let finishReason = Self.mapFinishReason(anthropicResponse.stopReason)
if finishReason == .contentFilter {
let fallbackRefusalText = if let category = anthropicResponse.stopDetails?.category {
"Request refused by Anthropic content filter (\(category))"
} else {
"Request refused by Anthropic content filter"
}
let refusalText = anthropicResponse.stopDetails?.explanation ?? fallbackRefusalText
return ProviderResponse(
text: refusalText,
usage: usage,
finishReason: finishReason,
toolCalls: nil,
reasoning: [],
assistantMessages: [],
isBillable: usage.outputTokens > 0,
)
}
// Convert tool calls if present
let toolCalls = anthropicResponse.content.compactMap { content -> AgentToolCall? in
var reasoning: [ProviderReasoningBlock] = []
var toolCalls: [AgentToolCall] = []
var assistantMessages: [ModelMessage] = []
for content in anthropicResponse.content {
switch content {
case .text:
return nil
case let .text(textContent):
if !textContent.text.isEmpty {
assistantMessages.append(.assistant(textContent.text))
}
case let .thinking(thinking):
let block = ProviderReasoningBlock(
text: thinking.thinking,
signature: thinking.signature,
type: thinking.type,
)
reasoning.append(block)
assistantMessages.append(ModelMessage(
role: .assistant,
content: [.text(thinking.thinking)],
channel: .thinking,
metadata: .init(customData: self.reasoningMetadata(
type: thinking.type,
signature: thinking.signature,
)),
))
case let .redactedThinking(thinking):
let block = ProviderReasoningBlock(
text: thinking.data,
type: thinking.type,
)
reasoning.append(block)
assistantMessages.append(ModelMessage(
role: .assistant,
content: [.text(thinking.data)],
channel: .thinking,
metadata: .init(customData: self.reasoningMetadata(type: thinking.type)),
))
case let .toolUse(toolUse):
// Convert input to AnyAgentToolValue dictionary
var arguments: [String: AnyAgentToolValue] = [:]
@ -257,11 +459,13 @@ public final class AnthropicProvider: ModelProvider {
}
}
return AgentToolCall(
let toolCall = AgentToolCall(
id: toolUse.id,
name: toolUse.name,
arguments: arguments,
)
toolCalls.append(toolCall)
assistantMessages.append(ModelMessage(role: .assistant, content: [.toolCall(toolCall)]))
}
}
@ -270,9 +474,61 @@ public final class AnthropicProvider: ModelProvider {
usage: usage,
finishReason: finishReason,
toolCalls: toolCalls.isEmpty ? nil : toolCalls,
reasoning: reasoning,
assistantMessages: assistantMessages,
)
}
private func reasoningMetadata(type: String, signature: String? = nil) -> [String: String] {
var metadata = [
"anthropic.thinking.model": self.reasoningModelId,
"anthropic.thinking.type": type,
"tachikoma.reasoning.provider": self.reasoningProvider,
"tachikoma.reasoning.model": self.reasoningModelId,
]
if let signature, !signature.isEmpty {
metadata["anthropic.thinking.signature"] = signature
}
if let reasoningBaseURL {
metadata["tachikoma.reasoning.base_url"] = reasoningBaseURL
}
return metadata
}
private func requiresSignedThinkingReplay(model: LanguageModel.Anthropic) -> Bool {
Self.isFable(model: model)
}
private func defaultMaxTokens(for model: LanguageModel.Anthropic) -> Int {
if Self.isFable(model: model) { return min(128_000, 16384) }
return 1024
}
private static func isFable(model: LanguageModel.Anthropic) -> Bool {
LanguageModel.Anthropic.isFable(modelId: model.modelId)
}
private static func hasStreamingRefusalRisk(model: LanguageModel.Anthropic) -> Bool {
LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: model.modelId)
}
private static func requiresExtendedNonStreamingTimeout(model: LanguageModel.Anthropic, maxTokens: Int) -> Bool {
self.isFable(model: model) || maxTokens >= 64000
}
static func mapFinishReason(_ stopReason: String?) -> FinishReason? {
switch stopReason {
case "end_turn": .stop
case "max_tokens": .length
case "tool_use": .toolCalls
case "stop_sequence": .stop
case "model_context_window_exceeded": .length
case "refusal": .contentFilter
case nil: nil
default: .other
}
}
private func applyAuth(to request: inout URLRequest, secret: String) {
switch self.auth {
case .apiKey:
@ -280,10 +536,19 @@ public final class AnthropicProvider: ModelProvider {
case .bearer:
request.setValue("Bearer " + secret, forHTTPHeaderField: "Authorization")
}
request.setValue(self.betaHeader, forHTTPHeaderField: "anthropic-beta")
if !self.betaHeader.isEmpty {
request.setValue(self.betaHeader, forHTTPHeaderField: "anthropic-beta")
}
}
public func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
guard !Self.hasStreamingRefusalRisk(model: self.model) else {
let message = "\(self.model.modelId) streaming is disabled because Anthropic refusals require rollback-aware handling"
throw TachikomaError.invalidConfiguration(
"\(message); use generateText instead",
)
}
let urlRequest = try self.makeURLRequest(for: request, stream: true)
// Debug logging only when explicitly enabled
@ -334,7 +599,7 @@ public final class AnthropicProvider: ModelProvider {
(Data, URLResponse),
Error,
>) in
URLSession.shared.dataTask(with: urlRequest) { data, response, error in
self.urlSession.dataTask(with: urlRequest) { data, response, error in
if let error {
continuation.resume(throwing: error)
} else if let data, let response {
@ -362,7 +627,7 @@ public final class AnthropicProvider: ModelProvider {
let lines = String(data: data, encoding: .utf8)?.components(separatedBy: "\n") ?? []
#else
// macOS/iOS: Use streaming API
let (bytes, response) = try await URLSession.shared.bytes(for: urlRequest)
let (bytes, response) = try await self.urlSession.bytes(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw TachikomaError.networkError(NSError(domain: "Invalid response", code: 0))
@ -386,6 +651,7 @@ public final class AnthropicProvider: ModelProvider {
var currentReasoningSignature: String?
var currentReasoningType: String?
var reasoningSignatureEmitted = false
var finishReason: FinishReason?
do {
for try await line in bytes.lines {
@ -419,7 +685,7 @@ public final class AnthropicProvider: ModelProvider {
currentReasoningType = nil
reasoningSignatureEmitted = false
}
continuation.yield(TextStreamDelta.done())
continuation.yield(.done(finishReason: finishReason))
break
}
@ -450,6 +716,12 @@ public final class AnthropicProvider: ModelProvider {
currentReasoningSignature = nil
currentReasoningType = block.type
reasoningSignatureEmitted = false
if block.type == "redacted_thinking", let data = block.data {
continuation.yield(TextStreamDelta.reasoning(
data,
type: "redacted_thinking",
))
}
continue
}
}
@ -554,7 +826,9 @@ public final class AnthropicProvider: ModelProvider {
case "message_delta":
// Message-level updates (usage, etc.)
// Usage is typically included in the done event, not separately
if let stopReason = event.delta?.stopReason {
finishReason = Self.mapFinishReason(stopReason)
}
continue
case "message_stop":
@ -574,7 +848,7 @@ public final class AnthropicProvider: ModelProvider {
currentReasoningType = nil
reasoningSignatureEmitted = false
}
continuation.yield(TextStreamDelta.done())
continuation.yield(.done(finishReason: finishReason))
default:
// Unknown event type, skip
@ -611,6 +885,7 @@ public final class AnthropicProvider: ModelProvider {
var currentReasoningSignature: String?
var currentReasoningType: String?
var reasoningSignatureEmitted = false
var finishReason: FinishReason?
do {
for line in lines {
@ -637,7 +912,7 @@ public final class AnthropicProvider: ModelProvider {
type: currentReasoningType,
))
}
continuation.yield(TextStreamDelta.done())
continuation.yield(.done(finishReason: finishReason))
break
}
@ -656,6 +931,12 @@ public final class AnthropicProvider: ModelProvider {
currentReasoningSignature = nil
currentReasoningType = block.type
reasoningSignatureEmitted = false
if block.type == "redacted_thinking", let data = block.data {
continuation.yield(TextStreamDelta.reasoning(
data,
type: "redacted_thinking",
))
}
}
case "content_block_delta":
if let delta = event.delta {
@ -678,6 +959,10 @@ public final class AnthropicProvider: ModelProvider {
accumulatedReasoning += thinking
}
}
case "message_delta":
if let stopReason = event.delta?.stopReason {
finishReason = Self.mapFinishReason(stopReason)
}
case "message_stop":
if !accumulatedText.isEmpty {
continuation.yield(TextStreamDelta.text(accumulatedText))
@ -689,7 +974,7 @@ public final class AnthropicProvider: ModelProvider {
type: currentReasoningType,
))
}
continuation.yield(TextStreamDelta.done())
continuation.yield(.done(finishReason: finishReason))
default:
continue
}
@ -753,6 +1038,37 @@ public final class AnthropicProvider: ModelProvider {
}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
enum ReasoningEndpointIdentity {
static func canonical(_ rawValue: String?) -> String? {
guard
let trimmed = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty,
var components = URLComponents(string: trimmed),
let scheme = components.scheme?.lowercased(),
let host = components.host?.lowercased() else
{
return nil
}
components.scheme = scheme
components.host = host
components.user = nil
components.password = nil
components.fragment = nil
while components.path.count > 1, components.path.hasSuffix("/") {
components.path.removeLast()
}
guard let value = components.string else { return nil }
guard let data = value.data(using: .utf8) else { return nil }
let digest = ReasoningEndpointHasher.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
return "sha256:\(digest)"
}
}
/// Provider for Ollama models
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public final class OllamaProvider: ModelProvider {

View File

@ -11,12 +11,59 @@ public typealias ProviderFactoryOverride = (LanguageModel, TachikomaConfiguratio
public final class TachikomaConfiguration: @unchecked Sendable {
// MARK: - Profile Directory (for config/credentials)
/// Name of the profile directory under the user's HOME used to store
/// configuration and credentials (e.g. \.tachikoma, \.peekaboo).
/// Name of the profile directory under the user's HOME, or an absolute path,
/// used to store configuration and credentials (e.g. \.tachikoma, \.peekaboo).
/// Defaults to ".tachikoma". Host applications (like Peekaboo) should set this
/// to their own folder name during startup.
/// to their own folder/path during startup.
public nonisolated(unsafe) static var profileDirectoryName: String = ".tachikoma"
public static var profileDirectoryPath: String {
let profile = self.profileDirectoryName
if self.isAbsoluteProfilePath(profile) || profile.hasPrefix("~") {
return NSString(string: profile).expandingTildeInPath
}
guard let homeDirectory = self.homeDirectoryPath else {
return profile
}
return "\(homeDirectory)/\(profile)"
}
private static func isAbsoluteProfilePath(_ profile: String) -> Bool {
if profile.hasPrefix("/") {
return true
}
#if os(Windows)
if profile.hasPrefix("\\\\") {
return true
}
if profile.count >= 3 {
let driveIndex = profile.index(after: profile.startIndex)
let separatorIndex = profile.index(after: driveIndex)
let drive = profile[profile.startIndex]
let separator = profile[separatorIndex]
return drive.isASCII && drive.isLetter && profile[driveIndex] == ":" &&
(separator == "\\" || separator == "/")
}
#endif
return false
}
private static var homeDirectoryPath: String? {
#if os(Windows)
let homeDirectory = ProcessInfo.processInfo.environment["USERPROFILE"] ??
((ProcessInfo.processInfo.environment["HOMEDRIVE"] ?? "") +
(ProcessInfo.processInfo.environment["HOMEPATH"] ?? ""))
return homeDirectory.isEmpty ? nil : homeDirectory
#else
return ProcessInfo.processInfo.environment["HOME"]
#endif
}
private let lock = NSLock()
private var _apiKeys: [String: String] = [:]
private var _baseURLs: [String: String] = [:]
@ -95,6 +142,10 @@ public final class TachikomaConfiguration: @unchecked Sendable {
return configuredKey
}
if provider == .minimaxCN, let sharedMiniMaxKey = self._apiKeys[Provider.minimax.identifier] {
return sharedMiniMaxKey
}
// Fall back to environment variable only if loadFromEnvironment is true
if self._loadFromEnvironment {
return provider.loadAPIKeyFromEnvironment()
@ -281,7 +332,17 @@ public final class TachikomaConfiguration: @unchecked Sendable {
self.loadFromEnvironment()
}
/// Load configuration from environment variables
/// Load configuration from environment variables.
///
/// Marked `@inline(never)` to work around a Swift release-mode optimizer
/// issue where inlining this function into the singleton-init path causes
/// the `_baseURLs[.anthropic]` write to be incorrectly eliminated, leaving
/// `AnthropicProvider.init` reading a stale (default) value via
/// `getBaseURL`. OpenAI / Ollama / MiniMax / Azure writes happen to
/// survive the optimization; only Anthropic is empirically affected.
/// Removing this annotation reintroduces the bug observed in
/// openclaw/Peekaboo release builds for `claude-*` models. See #17.
@inline(never)
private func loadFromEnvironment() {
// Load API keys for all standard providers from environment
for provider in Provider.standardProviders {
@ -294,6 +355,8 @@ public final class TachikomaConfiguration: @unchecked Sendable {
let urlMappings: [Provider: String] = [
.openai: "OPENAI_BASE_URL",
.anthropic: "ANTHROPIC_BASE_URL",
.minimax: "MINIMAX_BASE_URL",
.minimaxCN: "MINIMAX_CN_BASE_URL",
.ollama: "OLLAMA_BASE_URL",
.azureOpenAI: "AZURE_OPENAI_ENDPOINT",
]
@ -312,23 +375,13 @@ public final class TachikomaConfiguration: @unchecked Sendable {
/// Load configuration from credentials file
private func loadFromCredentials() {
// Load configuration from credentials file
#if os(Windows)
let homeDirectory = ProcessInfo.processInfo.environment["USERPROFILE"] ??
(ProcessInfo.processInfo.environment["HOMEDRIVE"] ?? "" +
(ProcessInfo.processInfo.environment["HOMEPATH"] ?? ""))
guard !homeDirectory.isEmpty else { return }
#else
guard let homeDirectory = ProcessInfo.processInfo.environment["HOME"] else {
return
}
#endif
// Primary: configured profile directory/path (e.g. .peekaboo or PEEKABOO_CONFIG_DIR)
let primaryCredentialsPath = "\(Self.profileDirectoryPath)/credentials"
// Primary: configured profile directory (e.g. .peekaboo)
let primaryCredentialsPath = "\(homeDirectory)/\(Self.profileDirectoryName)/credentials"
// Fallback: legacy .tachikoma directory for non-Peekaboo users
let fallbackCredentialsPath = "\(homeDirectory)/.tachikoma/credentials"
let fallbackCredentialsPath = Self.homeDirectoryPath.map { "\($0)/.tachikoma/credentials" }
let candidates = [primaryCredentialsPath, fallbackCredentialsPath]
let candidates = [primaryCredentialsPath, fallbackCredentialsPath].compactMap(\.self)
let credentialsPath = candidates.first { FileManager.default.fileExists(atPath: $0) }
guard let path = credentialsPath else { return }
let credentialsURL = URL(fileURLWithPath: path)
@ -356,46 +409,32 @@ public final class TachikomaConfiguration: @unchecked Sendable {
let key = components[0].trimmingCharacters(in: .whitespacesAndNewlines)
let value = components[1...].joined(separator: "=").trimmingCharacters(in: .whitespacesAndNewlines)
// Map credential keys to providers
let lowercaseKey = key.lowercased()
if lowercaseKey.contains("openai") {
self.setAPIKey(value, for: .openai)
} else if lowercaseKey.contains("anthropic") || lowercaseKey.contains("claude") {
self.setAPIKey(value, for: .anthropic)
} else if lowercaseKey.contains("grok") {
self.setAPIKey(value, for: .grok)
} else if lowercaseKey.contains("groq") {
self.setAPIKey(value, for: .groq)
} else if lowercaseKey.contains("mistral") {
self.setAPIKey(value, for: .mistral)
} else if lowercaseKey.contains("google") || lowercaseKey.contains("gemini") {
self.setAPIKey(value, for: .google)
} else if lowercaseKey.contains("ollama") {
self.setAPIKey(value, for: .ollama)
if let provider = Self.provider(forCredentialKey: key) {
self.setAPIKey(value, for: provider)
}
}
}
}
private static func provider(forCredentialKey key: String) -> Provider? {
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
return Provider.standardProviders.first { provider in
let keys = ([provider.environmentVariable] + provider.alternativeEnvironmentVariables)
.filter { !$0.isEmpty }
.map { $0.uppercased() }
return keys.contains(normalizedKey)
}
}
// MARK: - Persistence
/// Save current configuration to credentials file
public func saveCredentials() throws {
// Save current configuration to credentials file
#if os(Windows)
let homeDirectory = ProcessInfo.processInfo.environment["USERPROFILE"] ??
(ProcessInfo.processInfo.environment["HOMEDRIVE"] ?? "" +
(ProcessInfo.processInfo.environment["HOMEPATH"] ?? ""))
guard !homeDirectory.isEmpty else {
throw TachikomaError.invalidConfiguration("USERPROFILE directory not found")
let profileDir = Self.profileDirectoryPath
guard !profileDir.isEmpty else {
throw TachikomaError.invalidConfiguration("Profile directory not found")
}
#else
guard let homeDirectory = ProcessInfo.processInfo.environment["HOME"] else {
throw TachikomaError.invalidConfiguration("HOME directory not found")
}
#endif
let profileDir = "\(homeDirectory)/\(Self.profileDirectoryName)"
let credentialsPath = "\(profileDir)/credentials"
// Create directory if needed
@ -410,7 +449,8 @@ public final class TachikomaConfiguration: @unchecked Sendable {
self.lock.withLock {
for (provider, key) in self._apiKeys {
let envVarName = "\(provider.uppercased())_API_KEY"
let standardEnvVar = Provider.from(identifier: provider).environmentVariable
let envVarName = standardEnvVar.isEmpty ? "\(provider.uppercased())_API_KEY" : standardEnvVar
lines.append("\(envVarName)=\(key)")
}
}

View File

@ -7,6 +7,7 @@ public struct CustomProviderInfo: Sendable {
public let id: String
public let kind: Kind
public let baseURL: String
public let apiKey: String?
public let headers: [String: String]
public let models: [String: String] // map of alias->modelId (optional usage)
}
@ -29,6 +30,7 @@ public final class CustomProviderRegistry: @unchecked Sendable {
guard
let options = dict["options"] as? [String: Any],
let baseURL = options["baseURL"] as? String else { continue }
let apiKey = options["apiKey"] as? String
let headers = options["headers"] as? [String: String] ?? [:]
var models: [String: String] = [:]
if let modelsDict = dict["models"] as? [String: Any] {
@ -36,7 +38,14 @@ public final class CustomProviderRegistry: @unchecked Sendable {
if let m = (v as? [String: Any])?["name"] as? String { models[k] = m }
}
}
out[id] = CustomProviderInfo(id: id, kind: kind, baseURL: baseURL, headers: headers, models: models)
out[id] = CustomProviderInfo(
id: id,
kind: kind,
baseURL: baseURL,
apiKey: apiKey,
headers: headers,
models: models,
)
}
self.providers = out
}
@ -52,12 +61,7 @@ public final class CustomProviderRegistry: @unchecked Sendable {
// MARK: - Helpers
private static func profileDirectoryPath() -> String {
#if os(Windows)
let home = ProcessInfo.processInfo.environment["USERPROFILE"] ?? ""
#else
let home = ProcessInfo.processInfo.environment["HOME"] ?? ""
#endif
return "\(home)/\(TachikomaConfiguration.profileDirectoryName)"
TachikomaConfiguration.profileDirectoryPath
}
private static func profileConfigPath() -> String {

View File

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

View File

@ -35,10 +35,11 @@ public func generateText(
var currentMessages = messages
var allSteps: [GenerationStep] = []
var totalUsage = Usage(inputTokens: 0, outputTokens: 0)
var finalResponseStartIndex = messages.count
for stepIndex in 0..<maxSteps {
let request = ProviderRequest(
messages: currentMessages,
messages: currentMessages.sanitizedForProvider(model, configuration: resolvedConfiguration),
tools: tools,
settings: settings,
)
@ -51,8 +52,23 @@ public func generateText(
try await provider.generateText(request: request)
}
// Track usage with proper session management
if let usage = response.usage {
let isContentFiltered = response.finishReason == .contentFilter
let responseText = isContentFiltered ? "" : response.text
let responseToolCalls = isContentFiltered ? [] : (response.toolCalls ?? [])
let responseReasoning = isContentFiltered ? [] : response.reasoning
let responseAssistantMessages = isContentFiltered ? [] : response.assistantMessages
let responseMessageStartIndex = currentMessages.count
finalResponseStartIndex = responseMessageStartIndex
let responseHistoryMessages = model.responseHistoryMessages(
nativeMessages: responseAssistantMessages,
text: responseText,
reasoning: responseReasoning,
toolCalls: responseToolCalls,
configuration: resolvedConfiguration,
)
// Track billable usage with proper session management.
if response.isBillable, let usage = response.usage {
let actualSessionId = sessionId ?? "generation-\(UUID().uuidString)"
// Start session if not already started
@ -86,8 +102,8 @@ public func generateText(
// Create step record
let step = GenerationStep(
stepIndex: stepIndex,
text: response.text,
toolCalls: response.toolCalls ?? [],
text: responseText,
toolCalls: responseToolCalls,
toolResults: [],
usage: response.usage,
finishReason: response.finishReason,
@ -95,18 +111,26 @@ public func generateText(
allSteps.append(step)
// Add assistant message
var assistantContent: [ModelMessage.ContentPart] = [.text(response.text)]
if isContentFiltered {
break
}
if !responseHistoryMessages.isEmpty {
currentMessages.append(contentsOf: responseHistoryMessages)
if responseHistoryMessages.allSatisfy({ $0.channel == .thinking }) {
currentMessages.append(ModelMessage(
role: .assistant,
content: [.text("")],
metadata: .init(customData: ["tachikoma.internal.boundary": "reasoning_only"]),
))
}
}
// Handle tool calls
if let toolCalls = response.toolCalls, !toolCalls.isEmpty {
// Add tool calls to assistant message
assistantContent.append(contentsOf: toolCalls.map { .toolCall($0) })
currentMessages.append(ModelMessage(role: .assistant, content: assistantContent))
if !responseToolCalls.isEmpty {
// Execute tools
var toolResults: [AgentToolResult] = []
for toolCall in toolCalls {
for toolCall in responseToolCalls {
if let tool = tools?.first(where: { $0.name == toolCall.name }) {
do {
// Debug: Log tool call details in verbose mode
@ -124,7 +148,7 @@ public func generateText(
// Create execution context with full conversation and model info
let context = ToolExecutionContext(
messages: currentMessages,
messages: currentMessages.sanitizedForToolContext(),
model: model,
settings: settings,
sessionId: sessionId ?? "generation-\(UUID().uuidString)",
@ -161,8 +185,8 @@ public func generateText(
// Update step with tool results
allSteps[stepIndex] = GenerationStep(
stepIndex: stepIndex,
text: response.text,
toolCalls: toolCalls,
text: responseText,
toolCalls: responseToolCalls,
toolResults: toolResults,
usage: response.usage,
finishReason: response.finishReason,
@ -174,17 +198,17 @@ public func generateText(
}
} else {
// No tool calls, we're done
currentMessages.append(ModelMessage(role: .assistant, content: assistantContent))
break
}
}
// Extract final text from last step
var finalText = allSteps.last?.text ?? ""
let originalFinalText = finalText
var finalFinishReason = allSteps.last?.finishReason ?? .other
// Apply stop conditions if configured
if let stopCondition = settings.stopConditions {
if finalFinishReason != .contentFilter, let stopCondition = settings.stopConditions {
// Check if we should stop and truncate the text
if await stopCondition.shouldStop(text: finalText, delta: nil) {
// Truncate text based on the type of stop condition
@ -217,13 +241,16 @@ public func generateText(
}
}
}
let finalMessages = finalText == originalFinalText
? currentMessages
: currentMessages.replacingGeneratedAssistantText(after: finalResponseStartIndex, with: finalText)
return GenerateTextResult(
text: finalText,
usage: totalUsage,
finishReason: finalFinishReason,
steps: allSteps,
messages: currentMessages,
messages: finalMessages,
)
}
@ -254,6 +281,9 @@ public func streamText(
{
// Debug logging only when explicitly enabled via environment variable or verbose flag
let resolvedConfiguration = TachikomaConfiguration.resolve(configuration)
guard model.supportsStreaming else {
throw TachikomaError.invalidConfiguration("\(model.modelId) does not support streaming")
}
let debugEnabled = ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA"] != nil ||
resolvedConfiguration.verbose
if debugEnabled {
@ -275,7 +305,7 @@ public func streamText(
}
let request = ProviderRequest(
messages: messages,
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
tools: tools,
settings: settings,
)
@ -296,12 +326,6 @@ public func streamText(
stream = try await provider.streamText(request: request)
}
// Apply stop conditions if configured
if let stopCondition = settings.stopConditions {
// Wrap the stream with stop condition checking
stream = stream.stopWhen(stopCondition)
}
// Use provided session or create a new one for tracking streaming usage
let actualSessionId = sessionId ?? "streaming-\(UUID().uuidString)"
if sessionId == nil {
@ -311,23 +335,84 @@ public func streamText(
// Wrap the stream to track usage when it completes
let capturedModel = model
let capturedSessionId = actualSessionId
let capturedStream = stream
let shouldEndSession = sessionId == nil
let buffersUntilDone = model.buffersTextStreamUntilDone(settings: settings)
if !buffersUntilDone, let stopCondition = settings.stopConditions {
stream = stream.stopWhen(stopCondition)
}
let capturedStream = stream
let capturedStopCondition = buffersUntilDone ? settings.stopConditions : nil
let trackedStream = AsyncThrowingStream<TextStreamDelta, Error> { continuation in
Task {
do {
let totalInputTokens = 0
var totalOutputTokens = 0
var bufferedDeltas: [TextStreamDelta] = []
var bufferedVisibleText = ""
var didReceiveTerminal = false
var didTriggerLocalStop = false
for try await delta in capturedStream {
continuation.yield(delta)
func track(_ delta: TextStreamDelta) {
// Track tokens as they come in (approximate)
if case .textDelta = delta.type, let content = delta.content {
// Rough approximation: ~4 characters per token
totalOutputTokens += max(1, content.count / 4)
}
}
func yieldAndTrack(_ delta: TextStreamDelta) {
track(delta)
continuation.yield(delta)
}
if let capturedStopCondition {
await capturedStopCondition.reset()
}
for try await delta in capturedStream {
if buffersUntilDone, delta.type != .done {
if !didTriggerLocalStop {
bufferedDeltas.append(delta)
track(delta)
if
let capturedStopCondition,
case .textDelta = delta.type,
let content = delta.content
{
bufferedVisibleText += content
didTriggerLocalStop = await capturedStopCondition.shouldStop(
text: bufferedVisibleText,
delta: content,
)
}
}
continue
}
if case .done = delta.type {
didReceiveTerminal = true
if buffersUntilDone {
if delta.finishReason == .contentFilter {
bufferedDeltas.removeAll()
yieldAndTrack(delta)
} else {
for bufferedDelta in bufferedDeltas {
continuation.yield(bufferedDelta)
}
bufferedDeltas.removeAll()
if didTriggerLocalStop {
yieldAndTrack(TextStreamDelta.done(usage: delta.usage, finishReason: .stop))
} else {
yieldAndTrack(delta)
}
}
} else {
yieldAndTrack(delta)
}
} else {
yieldAndTrack(delta)
}
if case .done = delta.type {
// Record final usage (this is approximate for streaming)
@ -348,6 +433,10 @@ public func streamText(
}
}
if buffersUntilDone, !didReceiveTerminal {
throw TachikomaError.apiError("Stream ended before provider completion status was received")
}
continuation.finish()
} catch {
if shouldEndSession {
@ -392,7 +481,7 @@ public func generateObject<T: Codable & Sendable>(
let provider = try resolvedConfiguration.makeProvider(for: model)
let request = ProviderRequest(
messages: messages,
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
tools: nil,
settings: settings,
outputFormat: .json,
@ -406,6 +495,10 @@ public func generateObject<T: Codable & Sendable>(
try await provider.generateText(request: request)
}
if response.finishReason == .contentFilter {
throw TachikomaError.apiError("Response was blocked by the provider content filter")
}
// Parse the JSON response into the expected type
guard let jsonData = response.text.data(using: .utf8) else {
throw TachikomaError.invalidInput("Response text is not valid UTF-8")
@ -446,11 +539,14 @@ public func streamObject<T: Codable & Sendable>(
-> StreamObjectResult<T>
{
let resolvedConfiguration = TachikomaConfiguration.resolve(configuration)
guard model.supportsStreaming else {
throw TachikomaError.invalidConfiguration("\(model.modelId) does not support streaming")
}
let provider = try resolvedConfiguration.makeProvider(for: model)
// Create request with JSON output format
let request = ProviderRequest(
messages: messages,
messages: messages.sanitizedForProvider(model, configuration: resolvedConfiguration),
tools: nil,
settings: settings,
outputFormat: .json,
@ -458,6 +554,7 @@ public func streamObject<T: Codable & Sendable>(
// Get the text stream from the provider
let stream = try await provider.streamText(request: request)
let buffersUntilDone = model.buffersObjectStreamUntilDone(settings: settings)
// Create a new stream that attempts to parse partial JSON objects
let objectStream = AsyncThrowingStream<ObjectStreamDelta<T>, Error> { continuation in
@ -466,6 +563,37 @@ public func streamObject<T: Codable & Sendable>(
var accumulatedText = ""
var lastValidObject: T?
var hasStarted = false
var bufferedStartDelta: ObjectStreamDelta<T>?
var didFinishObject = false
func publishCompleteObject(allowLastValidObjectFallback: Bool) throws {
if buffersUntilDone, let bufferedStartDelta {
continuation.yield(bufferedStartDelta)
}
if
let jsonData = accumulatedText.data(using: .utf8),
let finalObject = try? JSONDecoder().decode(T.self, from: jsonData)
{
continuation.yield(ObjectStreamDelta(
type: .complete,
object: finalObject,
rawText: accumulatedText,
))
} else if allowLastValidObjectFallback, let lastValidObject {
// If we have a last valid object, use it as complete
continuation.yield(ObjectStreamDelta(
type: .complete,
object: lastValidObject,
rawText: accumulatedText,
))
} else {
throw TachikomaError.invalidInput(
"Failed to parse complete object from stream",
)
}
continuation.yield(ObjectStreamDelta(type: .done))
didFinishObject = true
}
for try await delta in stream {
if case .textDelta = delta.type, let content = delta.content {
@ -474,7 +602,16 @@ public func streamObject<T: Codable & Sendable>(
// Signal stream start
if !hasStarted {
hasStarted = true
continuation.yield(ObjectStreamDelta(type: .start))
let startDelta = ObjectStreamDelta<T>(type: .start)
if buffersUntilDone {
bufferedStartDelta = startDelta
} else {
continuation.yield(startDelta)
}
}
if buffersUntilDone {
continue
}
// Attempt to parse the accumulated JSON
@ -482,44 +619,51 @@ public func streamObject<T: Codable & Sendable>(
// Try to parse as complete object
if let object = try? JSONDecoder().decode(T.self, from: jsonData) {
lastValidObject = object
continuation.yield(ObjectStreamDelta(
let objectDelta = ObjectStreamDelta(
type: .partial,
object: object,
rawText: accumulatedText,
))
)
continuation.yield(objectDelta)
} else if let partialObject = attemptPartialParse(T.self, from: accumulatedText) {
// Attempt to parse as partial object
lastValidObject = partialObject
continuation.yield(ObjectStreamDelta(
let objectDelta = ObjectStreamDelta(
type: .partial,
object: partialObject,
rawText: accumulatedText,
))
)
continuation.yield(objectDelta)
}
}
} else if case .done = delta.type {
// Final parse attempt
if
let jsonData = accumulatedText.data(using: .utf8),
let finalObject = try? JSONDecoder().decode(T.self, from: jsonData)
{
continuation.yield(ObjectStreamDelta(
type: .complete,
object: finalObject,
rawText: accumulatedText,
))
} else if let lastValidObject {
// If we have a last valid object, use it as complete
continuation.yield(ObjectStreamDelta(
type: .complete,
object: lastValidObject,
rawText: accumulatedText,
))
} else {
throw TachikomaError.invalidInput(
"Failed to parse complete object from stream",
)
if delta.finishReason == .contentFilter {
throw TachikomaError.apiError("Response was blocked by the provider content filter")
}
try publishCompleteObject(allowLastValidObjectFallback: delta.finishReason == .stop || delta
.finishReason == nil)
}
}
if !didFinishObject, hasStarted {
if buffersUntilDone {
throw TachikomaError.apiError("Stream ended before provider completion status was received")
} else if
let jsonData = accumulatedText.data(using: .utf8),
let finalObject = try? JSONDecoder().decode(T.self, from: jsonData)
{
continuation.yield(ObjectStreamDelta(
type: .complete,
object: finalObject,
rawText: accumulatedText,
))
continuation.yield(ObjectStreamDelta(type: .done))
} else if let lastValidObject {
continuation.yield(ObjectStreamDelta(
type: .complete,
object: lastValidObject,
rawText: accumulatedText,
))
continuation.yield(ObjectStreamDelta(type: .done))
}
}
@ -599,6 +743,466 @@ private func fixPartialJSON(_ json: String) -> String {
return fixed
}
extension LanguageModel {
fileprivate func buffersTextStreamUntilDone(settings: GenerationSettings) -> Bool {
self.hasAnthropicStreamingRefusalRisk ||
settings.streamBuffering == .untilTerminal ||
(settings.stopConditions != nil && self.canEmitTerminalContentFilterAfterText)
}
fileprivate func buffersObjectStreamUntilDone(settings: GenerationSettings) -> Bool {
settings.streamBuffering == .untilTerminal ||
self.hasAnthropicStreamingRefusalRisk
}
private var hasAnthropicStreamingRefusalRisk: Bool {
switch self {
case let .anthropic(model):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: model.modelId)
case let .anthropicCompatible(modelId, _):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
case let .openRouter(modelId), let .together(modelId):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
case let .openaiCompatible(modelId, _):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
case let .custom(provider):
if
let parsed = ProviderParser.parse(provider.modelId),
CustomProviderRegistry.shared.get(parsed.provider)?.kind == .anthropic
{
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: parsed.model)
}
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: provider.modelId)
default:
return false
}
}
private var canEmitTerminalContentFilterAfterText: Bool {
switch self {
case .openai,
.openaiCompatible,
.openRouter,
.together,
.replicate,
.google,
.mistral,
.groq,
.grok,
.azureOpenAI:
return true
case let .custom(provider):
guard
let parsed = ProviderParser.parse(provider.modelId),
let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider) else
{
return false
}
switch registeredProvider.kind {
case .openai:
return true
case .anthropic:
return false
}
default:
return false
}
}
}
private struct ReasoningReplayTarget {
let provider: String
let modelId: String
let baseURL: String?
let allowsLegacyUnknown: Bool
func matches(_ customData: [String: String]) -> Bool {
guard customData["tachikoma.reasoning.provider"] == self.provider else {
return false
}
guard customData["tachikoma.reasoning.model"] == self.modelId else {
return false
}
return customData["tachikoma.reasoning.base_url"] == self.endpointIdentity
}
var endpointIdentity: String? {
ReasoningEndpointIdentity.canonical(self.baseURL)
}
}
extension [ModelMessage] {
fileprivate func replacingGeneratedAssistantText(after prefixCount: Int, with text: String) -> [ModelMessage] {
guard self.indices.contains(prefixCount) else {
return self
}
var messages = self
var cursor = text.startIndex
for messageIndex in prefixCount..<messages.count {
let message = messages[messageIndex]
guard message.role == .assistant, message.channel != .thinking else {
continue
}
var content: [ModelMessage.ContentPart] = []
for part in message.content {
guard case let .text(originalText) = part else {
content.append(part)
continue
}
guard cursor < text.endIndex else {
continue
}
let remainingCount = text.distance(from: cursor, to: text.endIndex)
let takeCount = Swift.min(originalText.count, remainingCount)
let endIndex = text.index(cursor, offsetBy: takeCount)
content.append(.text(String(text[cursor..<endIndex])))
cursor = endIndex
}
messages[messageIndex] = ModelMessage(
id: message.id,
role: message.role,
content: content,
timestamp: message.timestamp,
channel: message.channel,
metadata: message.metadata,
)
}
return messages
}
}
extension [ModelMessage] {
fileprivate func sanitizedForProvider(
_ model: LanguageModel,
configuration: TachikomaConfiguration,
)
-> [ModelMessage]
{
if let target = model.anthropicThinkingReplayTarget(configuration: configuration) {
var sanitized: [ModelMessage] = []
for message in self {
if message.isSyntheticReasoningBoundary {
if sanitized.last?.channel == .thinking {
sanitized.append(message)
}
continue
}
guard message.channel == .thinking else {
sanitized.append(message)
continue
}
guard !message.hasOpenRouterReasoningReplayMetadata else {
continue
}
guard let producerModel = message.metadata?.customData?["anthropic.thinking.model"] else {
if
target.allowsLegacyUnknown,
message.metadata?.customData?["anthropic.thinking.type"] != nil
{
sanitized.append(message)
}
continue
}
let customData = message.metadata?.customData ?? [:]
if producerModel == target.modelId, target.matches(customData) {
sanitized.append(message)
}
}
return sanitized
}
if let target = model.openRouterReasoningReplayTarget(configuration: configuration) {
var sanitized: [ModelMessage] = []
for message in self {
if message.isSyntheticReasoningBoundary {
if sanitized.last?.channel == .thinking {
sanitized.append(message)
}
continue
}
guard message.channel == .thinking else {
sanitized.append(message)
continue
}
guard message.hasOpenRouterReasoningReplayMetadata else {
continue
}
if target.matches(message.metadata?.customData ?? [:]) {
sanitized.append(message)
}
}
return sanitized
}
return self.filter { !$0.isSyntheticReasoningBoundary && $0.channel != .thinking }
}
}
extension ModelMessage {
private var hasAnthropicThinkingReplayMetadata: Bool {
guard let customData = metadata?.customData else { return false }
return customData["anthropic.thinking.model"] != nil ||
customData["anthropic.thinking.type"] != nil ||
customData["anthropic.thinking.signature"] != nil
}
fileprivate var hasOpenRouterReasoningReplayMetadata: Bool {
guard let customData = metadata?.customData else { return false }
return customData["openrouter.reasoning_details"] != nil ||
customData["openrouter.reasoning"] != nil
}
private var hasProviderReasoningReplayMetadata: Bool {
self.hasAnthropicThinkingReplayMetadata || self.hasOpenRouterReasoningReplayMetadata
}
fileprivate var isSyntheticReasoningBoundary: Bool {
metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only"
}
}
extension [ModelMessage] {
fileprivate func sanitizedForToolContext() -> [ModelMessage] {
self.filter { $0.channel != .thinking && !$0.isSyntheticReasoningBoundary }
}
fileprivate func containsAssistantText(_ text: String) -> Bool {
guard !text.isEmpty else { return true }
let assistantTexts = self.flatMap { message -> [String] in
guard message.role == .assistant, message.channel != .thinking else {
return []
}
return message.content.compactMap { part in
if case let .text(value) = part {
return value
}
return nil
}
}
return assistantTexts.contains(text) || assistantTexts.joined() == text
}
fileprivate func containsReasoningBlock(_ reasoning: ProviderReasoningBlock) -> Bool {
self.contains { message in
message.role == .assistant && message.channel == .thinking && message.content.contains { part in
guard case let .text(value) = part else { return false }
if let signature = reasoning.signature, !signature.isEmpty {
return message.metadata?.customData?["anthropic.thinking.signature"] == signature ||
message.metadata?.customData?["tachikoma.reasoning.signature"] == signature
}
return value == reasoning.text
}
}
}
fileprivate func containsToolCall(id: String) -> Bool {
self.contains { message in
message.role == .assistant && message.content.contains { part in
if case let .toolCall(toolCall) = part {
return toolCall.id == id
}
return false
}
}
}
}
extension LanguageModel {
fileprivate func responseHistoryMessages(
nativeMessages: [ModelMessage],
text: String,
reasoning: [ProviderReasoningBlock],
toolCalls: [AgentToolCall],
configuration: TachikomaConfiguration,
)
-> [ModelMessage]
{
var history = nativeMessages
for reasoningBlock in reasoning where !history.containsReasoningBlock(reasoningBlock) {
history.append(ModelMessage(
role: .assistant,
content: [.text(reasoningBlock.text)],
channel: .thinking,
metadata: .init(customData: self.anthropicThinkingMetadata(
for: reasoningBlock,
configuration: configuration,
)),
))
}
let missingToolCalls = toolCalls.filter { !history.containsToolCall(id: $0.id) }
let isMissingText = !history.containsAssistantText(text)
let needsFallbackBoundary = nativeMessages.isEmpty && text.isEmpty && missingToolCalls.isEmpty
guard isMissingText || !missingToolCalls.isEmpty || needsFallbackBoundary else {
return history
}
var fallbackContent: [ModelMessage.ContentPart] = []
if isMissingText || needsFallbackBoundary {
fallbackContent.append(.text(text))
}
fallbackContent.append(contentsOf: missingToolCalls.map { .toolCall($0) })
let fallbackMetadata = needsFallbackBoundary
? MessageMetadata(customData: ["tachikoma.internal.boundary": "reasoning_only"])
: nil
history.append(ModelMessage(role: .assistant, content: fallbackContent, metadata: fallbackMetadata))
return history
}
fileprivate func anthropicThinkingReplayTarget(configuration: TachikomaConfiguration) -> ReasoningReplayTarget? {
switch self {
case let .anthropic(model):
return ReasoningReplayTarget(
provider: "anthropic",
modelId: model.modelId,
baseURL: configuration.getBaseURL(for: .anthropic) ?? Provider.anthropic.defaultBaseURL,
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: model.modelId),
)
case let .anthropicCompatible(modelId, baseURL):
return ReasoningReplayTarget(
provider: "anthropic-compatible",
modelId: modelId,
baseURL: baseURL,
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: modelId),
)
case let .minimax(model):
return ReasoningReplayTarget(
provider: "minimax",
modelId: model.modelId,
baseURL: configuration.getBaseURL(for: .minimax) ?? Provider.minimax.defaultBaseURL,
allowsLegacyUnknown: true,
)
case let .minimaxCN(model):
return ReasoningReplayTarget(
provider: "minimax-cn",
modelId: model.modelId,
baseURL: configuration.getBaseURL(for: .minimaxCN) ?? Provider.minimaxCN.defaultBaseURL,
allowsLegacyUnknown: true,
)
case let .custom(provider):
if let directAnthropicProvider = provider as? AnthropicProvider {
return ReasoningReplayTarget(
provider: "anthropic",
modelId: directAnthropicProvider.modelId,
baseURL: directAnthropicProvider.baseURL ?? Provider.anthropic.defaultBaseURL,
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: directAnthropicProvider.modelId),
)
}
if let compatibleProvider = provider as? AnthropicCompatibleProvider {
return ReasoningReplayTarget(
provider: "anthropic-compatible",
modelId: compatibleProvider.modelId,
baseURL: compatibleProvider.baseURL,
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: compatibleProvider.modelId),
)
}
guard
let parsed = ProviderParser.parse(provider.modelId),
let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider),
registeredProvider.kind == .anthropic else
{
return provider.modelId.contains("claude") || provider.modelId.contains("anthropic")
? ReasoningReplayTarget(
provider: "custom-anthropic",
modelId: provider.modelId,
baseURL: provider.baseURL,
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: provider.modelId),
)
: nil
}
return ReasoningReplayTarget(
provider: "custom-anthropic",
modelId: parsed.model,
baseURL: registeredProvider.baseURL,
allowsLegacyUnknown: !LanguageModel.Anthropic.isFable(modelId: parsed.model),
)
default:
return nil
}
}
fileprivate func openRouterReasoningReplayTarget(configuration: TachikomaConfiguration) -> ReasoningReplayTarget? {
switch self {
case let .openRouter(modelId):
ReasoningReplayTarget(
provider: "openrouter",
modelId: modelId,
baseURL: configuration.getBaseURL(for: .custom("openrouter")) ?? "https://openrouter.ai/api/v1",
allowsLegacyUnknown: false,
)
default:
nil
}
}
private func anthropicThinkingMetadata(
for reasoning: ProviderReasoningBlock,
configuration: TachikomaConfiguration,
)
-> [String: String]
{
if
let rawJSON = reasoning.rawJSON,
let target = self.openRouterReasoningReplayTarget(configuration: configuration)
{
var metadata = [
"openrouter.reasoning_details": rawJSON,
"tachikoma.reasoning.type": reasoning.type,
"tachikoma.reasoning.provider": target.provider,
"tachikoma.reasoning.model": target.modelId,
]
if let endpointIdentity = target.endpointIdentity {
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
}
return metadata
}
if
reasoning.type == "openrouter_reasoning",
let target = self.openRouterReasoningReplayTarget(configuration: configuration)
{
var metadata = [
"openrouter.reasoning": reasoning.text,
"tachikoma.reasoning.type": reasoning.type,
"tachikoma.reasoning.provider": target.provider,
"tachikoma.reasoning.model": target.modelId,
]
if let endpointIdentity = target.endpointIdentity {
metadata["tachikoma.reasoning.base_url"] = endpointIdentity
}
return metadata
}
guard let target = self.anthropicThinkingReplayTarget(configuration: configuration) else {
var customData = ["tachikoma.reasoning.type": reasoning.type]
if let signature = reasoning.signature, !signature.isEmpty {
customData["tachikoma.reasoning.signature"] = signature
}
return customData
}
var customData = [
"anthropic.thinking.type": reasoning.type,
"anthropic.thinking.model": target.modelId,
"tachikoma.reasoning.provider": target.provider,
"tachikoma.reasoning.model": target.modelId,
]
if let endpointIdentity = target.endpointIdentity {
customData["tachikoma.reasoning.base_url"] = endpointIdentity
}
if let signature = reasoning.signature, !signature.isEmpty {
customData["anthropic.thinking.signature"] = signature
}
return customData
}
}
// MARK: - Convenience Functions
/// Simple text generation from a prompt (convenience wrapper) - with Model enum
@ -670,7 +1274,7 @@ public func analyze(
model
} else {
// Use a vision-capable model by default
.openai(.gpt4o)
.openai(.gpt55)
}
// Ensure the model supports vision
@ -746,7 +1350,7 @@ public func analyze(
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public func stream(
_ prompt: String,
using model: LanguageModel = .default,
using model: LanguageModel = .defaultStreaming,
system: String? = nil,
maxTokens: Int? = nil,
temperature: Double? = nil,

View File

@ -206,6 +206,10 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
"ollama:\(submodel.modelId)"
case let .lmstudio(submodel):
"lmstudio:\(submodel.modelId)"
case let .minimax(submodel):
"minimax:\(submodel.modelId)"
case let .minimaxCN(submodel):
"minimax-cn:\(submodel.modelId)"
case let .openRouter(modelId):
"openrouter:\(modelId)"
case let .together(modelId):
@ -237,46 +241,16 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
excludedParameters: ["temperature", "topP", "frequencyPenalty", "presencePenalty"],
)
self.capabilities["openai:gpt-5.1"] = gpt5Capabilities
self.capabilities["openai:gpt-5.2"] = gpt5Capabilities
self.capabilities["openai:chat-latest"] = gpt5Capabilities
self.capabilities["openai:gpt-5-chat-latest"] = gpt5Capabilities
self.capabilities["openai:gpt-5.5"] = gpt5Capabilities
self.capabilities["openai:gpt-5.4"] = gpt5Capabilities
self.capabilities["openai:gpt-5.4-mini"] = gpt5Capabilities
self.capabilities["openai:gpt-5.4-nano"] = gpt5Capabilities
self.capabilities["openai:gpt-5"] = gpt5Capabilities
self.capabilities["openai:gpt-5-pro"] = gpt5Capabilities
self.capabilities["openai:gpt-5-mini"] = gpt5Capabilities
self.capabilities["openai:gpt-5-nano"] = gpt5Capabilities
self.capabilities["openai:gpt-5-thinking"] = gpt5Capabilities
self.capabilities["openai:gpt-5-thinking-mini"] = gpt5Capabilities
self.capabilities["openai:gpt-5-thinking-nano"] = gpt5Capabilities
self.capabilities["openai:gpt-5-chat-latest"] = gpt5Capabilities
// O4/GPT-5 reasoning models (fixed temperature, reasoning effort)
let reasoningCapabilities = ModelParameterCapabilities(
supportsTemperature: false,
supportsTopP: false,
supportedProviderOptions: .init(
supportsReasoningEffort: true,
supportsPreviousResponseId: true,
),
forcedTemperature: 1.0,
excludedParameters: ["temperature", "topP"],
)
self.capabilities["openai:o4"] = reasoningCapabilities
self.capabilities["openai:o4-mini"] = reasoningCapabilities
// Standard GPT-4 models
let gpt4Capabilities = ModelParameterCapabilities(
supportedProviderOptions: .init(
supportsParallelToolCalls: true,
supportsResponseFormat: true,
supportsLogprobs: true,
),
)
self.capabilities["openai:gpt-4o"] = gpt4Capabilities
self.capabilities["openai:gpt-4o-mini"] = gpt4Capabilities
self.capabilities["openai:gpt-4.1"] = gpt4Capabilities
self.capabilities["openai:gpt-4.1-mini"] = gpt4Capabilities
self.capabilities["openai:gpt-4-turbo"] = gpt4Capabilities
// Claude 4 models with thinking
let claude4Capabilities = ModelParameterCapabilities(
@ -286,11 +260,25 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
// Fable 5 and Opus 4.7+ map requested thinking to Anthropic's adaptive thinking request shape.
let claudeAdaptiveThinkingCapabilities = ModelParameterCapabilities(
supportsTemperature: false,
supportsTopP: false,
supportsTopK: false,
supportedProviderOptions: .init(
supportsThinking: true,
supportsCacheControl: true,
),
excludedParameters: ["temperature", "topP", "topK"],
)
self.capabilities["anthropic:claude-fable-5"] = claudeAdaptiveThinkingCapabilities
self.capabilities["anthropic:claude-opus-4-8"] = claudeAdaptiveThinkingCapabilities
self.capabilities["anthropic:claude-opus-4-7"] = claudeAdaptiveThinkingCapabilities
self.capabilities["anthropic:claude-opus-4-5"] = claude4Capabilities
self.capabilities["anthropic:claude-opus-4-1-20250805"] = claude4Capabilities
self.capabilities["anthropic:claude-sonnet-4-20250514"] = claude4Capabilities
self.capabilities["anthropic:claude-sonnet-4-6"] = claude4Capabilities
self.capabilities["anthropic:claude-sonnet-4-5-20250929"] = claude4Capabilities
self.capabilities["anthropic:claude-haiku-4.5"] = claude4Capabilities
self.capabilities["anthropic:claude-haiku-4-5"] = claude4Capabilities
// Google Gemini with thinking
let geminiCapabilities = ModelParameterCapabilities(
@ -301,6 +289,9 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
self.capabilities["google:gemini-3.5-flash"] = geminiCapabilities
self.capabilities["google:gemini-3.1-pro-preview"] = geminiCapabilities
self.capabilities["google:gemini-3.1-flash-lite"] = geminiCapabilities
self.capabilities["google:gemini-2.5-pro"] = geminiCapabilities
self.capabilities["google:gemini-2.5-flash"] = geminiCapabilities
self.capabilities["google:gemini-2.5-flash-lite"] = geminiCapabilities
@ -314,8 +305,12 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
self.capabilities["mistral:mistral-large-2"] = mistralCapabilities
self.capabilities["mistral:codestral"] = mistralCapabilities
self.capabilities["mistral:mistral-large-latest"] = mistralCapabilities
self.capabilities["mistral:mistral-medium-latest"] = mistralCapabilities
self.capabilities["mistral:mistral-medium-3-5"] = mistralCapabilities
self.capabilities["mistral:mistral-small-latest"] = mistralCapabilities
self.capabilities["mistral:open-mistral-nemo-2407"] = mistralCapabilities
self.capabilities["mistral:codestral-latest"] = mistralCapabilities
// Groq models (ultra-fast inference)
let groqCapabilities = ModelParameterCapabilities(
@ -324,12 +319,12 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
self.capabilities["groq:llama-3.1-70b"] = groqCapabilities
self.capabilities["groq:llama-3.1-8b"] = groqCapabilities
self.capabilities["groq:llama-3-70b"] = groqCapabilities
self.capabilities["groq:llama-3-8b"] = groqCapabilities
self.capabilities["groq:mixtral-8x7b"] = groqCapabilities
self.capabilities["groq:gemma2-9b"] = groqCapabilities
self.capabilities["groq:openai/gpt-oss-120b"] = groqCapabilities
self.capabilities["groq:openai/gpt-oss-20b"] = groqCapabilities
self.capabilities["groq:llama-3.3-70b-versatile"] = groqCapabilities
self.capabilities["groq:llama-3.1-8b-instant"] = groqCapabilities
self.capabilities["groq:meta-llama/llama-4-maverick-17b-128e-instruct"] = groqCapabilities
self.capabilities["groq:meta-llama/llama-4-scout-17b-16e-instruct"] = groqCapabilities
// Grok models
let grokCapabilities = ModelParameterCapabilities(
@ -339,17 +334,9 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
),
)
self.capabilities["grok:grok-4-0709"] = grokCapabilities
self.capabilities["grok:grok-4-fast-reasoning"] = grokCapabilities
self.capabilities["grok:grok-4-fast-non-reasoning"] = grokCapabilities
self.capabilities["grok:grok-code-fast-1"] = grokCapabilities
self.capabilities["grok:grok-3"] = grokCapabilities
self.capabilities["grok:grok-3-mini"] = grokCapabilities
self.capabilities["grok:grok-2-1212"] = grokCapabilities
self.capabilities["grok:grok-2-vision-1212"] = grokCapabilities
self.capabilities["grok:grok-2-image-1212"] = grokCapabilities
self.capabilities["grok:grok-vision-beta"] = grokCapabilities
self.capabilities["grok:grok-beta"] = grokCapabilities
self.capabilities["grok:grok-4.3"] = grokCapabilities
self.capabilities["grok:grok-4.20-0309-reasoning"] = grokCapabilities
self.capabilities["grok:grok-4.20-0309-non-reasoning"] = grokCapabilities
}
private func defaultCapabilities(for model: LanguageModel) -> ModelParameterCapabilities {
@ -359,6 +346,19 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
return registered
}
if self.isAnthropicFableCompatible(model) {
return ModelParameterCapabilities(
supportsTemperature: false,
supportsTopP: false,
supportsTopK: false,
supportedProviderOptions: .init(
supportsThinking: true,
supportsCacheControl: true,
),
excludedParameters: ["temperature", "topP", "topK"],
)
}
// Return provider-based defaults
switch model {
case .openai:
@ -369,6 +369,7 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
// Default Anthropic capabilities
return ModelParameterCapabilities(
supportedProviderOptions: .init(
supportsThinking: true,
supportsCacheControl: true,
),
)
@ -388,11 +389,38 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
supportsSeed: true,
)
case .minimax, .minimaxCN:
return ModelParameterCapabilities()
default:
// Default capabilities for unknown models
return ModelParameterCapabilities()
}
}
private func isAnthropicFableCompatible(_ model: LanguageModel) -> Bool {
switch model {
case let .anthropic(anthropic):
return LanguageModel.Anthropic.isFable(modelId: anthropic.modelId)
case let .anthropicCompatible(modelId, _):
return LanguageModel.Anthropic.isFable(modelId: modelId)
case let .openRouter(modelId),
let .openaiCompatible(modelId, _),
let .together(modelId):
return LanguageModel.Anthropic.isFable(modelId: modelId)
case let .custom(provider):
guard
let parsed = ProviderParser.parse(provider.modelId),
LanguageModel.Anthropic.isFable(modelId: parsed.model),
CustomProviderRegistry.shared.get(parsed.provider)?.kind == .anthropic else
{
return false
}
return true
default:
return false
}
}
}
// MARK: - GenerationSettings Extension
@ -406,6 +434,7 @@ extension GenerationSettings {
var adjustedTemperature = temperature
var adjustedTopP = topP
var adjustedTopK = topK
var adjustedFrequencyPenalty = frequencyPenalty
var adjustedPresencePenalty = presencePenalty
var adjustedProviderOptions = providerOptions
@ -417,6 +446,9 @@ extension GenerationSettings {
if capabilities.excludedParameters.contains("topP") {
adjustedTopP = nil
}
if capabilities.excludedParameters.contains("topK") {
adjustedTopK = nil
}
if capabilities.excludedParameters.contains("frequencyPenalty") {
adjustedFrequencyPenalty = nil
}
@ -440,7 +472,7 @@ extension GenerationSettings {
maxTokens: maxTokens,
temperature: adjustedTemperature,
topP: adjustedTopP,
topK: topK,
topK: adjustedTopK,
frequencyPenalty: adjustedFrequencyPenalty,
presencePenalty: adjustedPresencePenalty,
stopSequences: stopSequences,
@ -448,6 +480,7 @@ extension GenerationSettings {
stopConditions: stopConditions,
seed: seed,
providerOptions: adjustedProviderOptions,
streamBuffering: self.streamBuffering,
)
}

View File

@ -36,14 +36,24 @@ struct OpenAICompatibleHelper {
}
// Extract stop sequences from stop conditions
let stopSequences = Self.extractStopSequences(from: request.settings.stopConditions)
let settings = Self.validatedSettings(
request.settings,
providerName: providerName,
modelId: modelId,
baseURL: baseURL,
)
let stopSequences = Self.extractStopSequences(from: settings.stopConditions)
// Convert request to OpenAI-compatible format
let openAIRequest = try OpenAIChatRequest(
model: modelId,
messages: convertMessages(request.messages),
temperature: request.settings.temperature,
maxTokens: request.settings.maxTokens,
messages: convertMessages(
request.messages,
replayOpenRouterReasoningForModel: providerName == "OpenRouter" ? modelId : nil,
replayOpenRouterReasoningForBaseURL: providerName == "OpenRouter" ? baseURL : nil,
),
temperature: settings.temperature,
maxTokens: settings.maxTokens,
tools: request.tools?.compactMap { try self.convertTool($0) },
stream: false,
stop: stopSequences.isEmpty ? nil : stopSequences,
@ -100,14 +110,9 @@ struct OpenAICompatibleHelper {
let usage = openAIResponse.usage.map {
Usage(inputTokens: $0.promptTokens ?? 0, outputTokens: $0.completionTokens ?? 0)
}
let reasoning = Self.reasoningBlocks(from: choice.message)
let finishReason: FinishReason? = switch choice.finishReason {
case "stop": .stop
case "length": .length
case "tool_calls": .toolCalls
case "content_filter": .contentFilter
default: .other
}
let finishReason = Self.mapFinishReason(choice.finishReason)
// Convert tool calls if present
let toolCalls = choice.message.toolCalls?.compactMap { openAIToolCall -> AgentToolCall? in
@ -142,6 +147,7 @@ struct OpenAICompatibleHelper {
usage: usage,
finishReason: finishReason,
toolCalls: toolCalls,
reasoning: reasoning,
)
}
@ -173,14 +179,27 @@ struct OpenAICompatibleHelper {
}
// Extract stop sequences from stop conditions
let stopSequences = Self.extractStopSequences(from: request.settings.stopConditions)
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId) else {
throw TachikomaError.invalidConfiguration("\(modelId) does not support streaming")
}
let settings = Self.validatedSettings(
request.settings,
providerName: providerName,
modelId: modelId,
baseURL: baseURL,
)
let stopSequences = Self.extractStopSequences(from: settings.stopConditions)
// Convert request to OpenAI-compatible format
let openAIRequest = try OpenAIChatRequest(
model: modelId,
messages: convertMessages(request.messages),
temperature: request.settings.temperature,
maxTokens: request.settings.maxTokens,
messages: convertMessages(
request.messages,
replayOpenRouterReasoningForModel: providerName == "OpenRouter" ? modelId : nil,
replayOpenRouterReasoningForBaseURL: providerName == "OpenRouter" ? baseURL : nil,
),
temperature: settings.temperature,
maxTokens: settings.maxTokens,
tools: request.tools?.compactMap { try self.convertTool($0) },
stream: true,
stop: stopSequences.isEmpty ? nil : stopSequences,
@ -346,8 +365,10 @@ struct OpenAICompatibleHelper {
}
}
if choice.finishReason != nil {
continuation.yield(TextStreamDelta.done())
if let finishReason = choice.finishReason {
continuation.yield(TextStreamDelta.done(
finishReason: Self.mapFinishReason(finishReason),
))
break
}
}
@ -427,9 +448,10 @@ struct OpenAICompatibleHelper {
}
if let finishReason = choice.finishReason {
if finishReason == "stop" || finishReason == "tool_calls" {
continuation.yield(TextStreamDelta.done())
}
continuation.yield(TextStreamDelta.done(
finishReason: Self.mapFinishReason(finishReason),
))
break
}
}
} catch {
@ -456,6 +478,39 @@ struct OpenAICompatibleHelper {
// MARK: - Helper Methods
private static func mapFinishReason(_ reason: String?) -> FinishReason? {
switch reason {
case "stop": .stop
case "length": .length
case "tool_calls": .toolCalls
case "content_filter": .contentFilter
case nil: nil
default: .other
}
}
private static func validatedSettings(
_ settings: GenerationSettings,
providerName: String,
modelId: String,
baseURL: String,
)
-> GenerationSettings
{
settings.validated(for: self.languageModel(providerName: providerName, modelId: modelId, baseURL: baseURL))
}
private static func languageModel(providerName: String, modelId: String, baseURL: String) -> LanguageModel {
switch providerName.lowercased() {
case "openrouter":
.openRouter(modelId: modelId)
case "together":
.together(modelId: modelId)
default:
.openaiCompatible(modelId: modelId, baseURL: baseURL)
}
}
/// Extract native stop sequences from stop conditions
private static func extractStopSequences(from stopCondition: (any StopCondition)?) -> [String] {
// Extract native stop sequences from stop conditions
@ -512,18 +567,55 @@ struct OpenAICompatibleHelper {
}
}
private static func convertMessages(_ messages: [ModelMessage]) throws -> [OpenAIChatMessage] {
messages.map { message in
private static func convertMessages(
_ messages: [ModelMessage],
replayOpenRouterReasoningForModel modelId: String?,
replayOpenRouterReasoningForBaseURL baseURL: String?,
) throws
-> [OpenAIChatMessage]
{
var converted: [OpenAIChatMessage] = []
var pendingReasoningDetails: [JSONValue] = []
var pendingReasoningText: [String] = []
let endpointIdentity = ReasoningEndpointIdentity.canonical(baseURL)
for message in messages {
if
message.channel == .thinking,
let customData = message.metadata?.customData,
customData["tachikoma.reasoning.provider"] == "openrouter",
customData["tachikoma.reasoning.model"] == modelId,
customData["tachikoma.reasoning.base_url"] == endpointIdentity,
let rawReasoningDetails = customData["openrouter.reasoning_details"]
{
pendingReasoningDetails.append(contentsOf: Self.decodeReasoningDetails(rawReasoningDetails))
continue
}
if
message.channel == .thinking,
let customData = message.metadata?.customData,
customData["tachikoma.reasoning.provider"] == "openrouter",
customData["tachikoma.reasoning.model"] == modelId,
customData["tachikoma.reasoning.base_url"] == endpointIdentity,
let reasoning = customData["openrouter.reasoning"]
{
pendingReasoningText.append(reasoning)
continue
}
if message.channel == .thinking {
continue
}
switch message.role {
case .system:
return OpenAIChatMessage(role: "system", content: message.content.compactMap { part in
converted.append(OpenAIChatMessage(role: "system", content: message.content.compactMap { part in
if case let .text(text) = part { return text }
return nil
}.joined())
}.joined()))
case .user:
if message.content.count == 1, case let .text(text) = message.content.first! {
// Simple text message
return OpenAIChatMessage(role: "user", content: text)
converted.append(OpenAIChatMessage(role: "user", content: text))
} else {
// Multi-modal message
let content = message.content.compactMap { contentPart -> OpenAIChatMessageContent? in
@ -540,7 +632,7 @@ struct OpenAICompatibleHelper {
return nil // Skip tool calls and results in user messages
}
}
return OpenAIChatMessage(role: "user", content: content)
converted.append(OpenAIChatMessage(role: "user", content: content))
}
case .assistant:
// Check if this assistant message contains tool calls
@ -571,15 +663,25 @@ struct OpenAICompatibleHelper {
// If we have tool calls, create a message with tool calls
if !toolCalls.isEmpty {
return OpenAIChatMessage(
converted.append(OpenAIChatMessage(
role: "assistant",
content: textContent.isEmpty ? nil : textContent,
toolCalls: toolCalls,
)
reasoning: pendingReasoningText.isEmpty ? nil : pendingReasoningText.joined(separator: "\n"),
reasoningDetails: pendingReasoningDetails.isEmpty ? nil : pendingReasoningDetails,
))
} else {
// Regular text message
return OpenAIChatMessage(role: "assistant", content: textContent)
converted.append(OpenAIChatMessage(
role: "assistant",
content: textContent,
toolCalls: nil,
reasoning: pendingReasoningText.isEmpty ? nil : pendingReasoningText.joined(separator: "\n"),
reasoningDetails: pendingReasoningDetails.isEmpty ? nil : pendingReasoningDetails,
))
}
pendingReasoningText.removeAll()
pendingReasoningDetails.removeAll()
case .tool:
// Extract tool call ID and result content from tool result
var toolCallId: String?
@ -598,9 +700,44 @@ struct OpenAICompatibleHelper {
}
}
return OpenAIChatMessage(role: "tool", content: resultContent, toolCallId: toolCallId)
converted.append(OpenAIChatMessage(role: "tool", content: resultContent, toolCallId: toolCallId))
}
}
return converted
}
private static func reasoningBlocks(from message: OpenAIChatResponse.Message) -> [ProviderReasoningBlock] {
var blocks: [ProviderReasoningBlock] = []
if let details = message.reasoningDetails, !details.isEmpty {
blocks.append(ProviderReasoningBlock(
text: message.reasoning ?? "",
type: "openrouter_reasoning_details",
rawJSON: Self.encodeReasoningDetails(details),
))
} else if let reasoning = message.reasoning, !reasoning.isEmpty {
blocks.append(ProviderReasoningBlock(
text: reasoning,
type: "openrouter_reasoning",
rawJSON: nil,
))
}
return blocks
}
private static func encodeReasoningDetails(_ details: [JSONValue]) -> String? {
guard let data = try? JSONEncoder().encode(details) else { return nil }
return String(data: data, encoding: .utf8)
}
private static func decodeReasoningDetails(_ rawJSON: String) -> [JSONValue] {
guard
let data = rawJSON.data(using: .utf8),
let details = try? JSONDecoder().decode([JSONValue].self, from: data) else
{
return []
}
return details
}
private static func convertTool(_ tool: AgentTool) throws -> OpenAITool {

View File

@ -41,6 +41,12 @@ public enum Provider: Sendable, Hashable, Codable {
/// Google provider (Gemini models)
case google
/// MiniMax provider (Anthropic-compatible hosted models)
case minimax
/// MiniMax China provider (Anthropic-compatible hosted models)
case minimaxCN
/// Ollama provider (local model hosting)
case ollama
@ -62,6 +68,8 @@ public enum Provider: Sendable, Hashable, Codable {
case .groq: "groq"
case .mistral: "mistral"
case .google: "google"
case .minimax: "minimax"
case .minimaxCN: "minimax-cn"
case .ollama: "ollama"
case .lmstudio: "lmstudio"
case .azureOpenAI: "azure-openai"
@ -78,6 +86,8 @@ public enum Provider: Sendable, Hashable, Codable {
case .groq: "Groq"
case .mistral: "Mistral"
case .google: "Google"
case .minimax: "MiniMax"
case .minimaxCN: "MiniMax China"
case .ollama: "Ollama"
case .lmstudio: "LMStudio"
case .azureOpenAI: "Azure OpenAI"
@ -94,6 +104,8 @@ public enum Provider: Sendable, Hashable, Codable {
case .groq: "GROQ_API_KEY"
case .mistral: "MISTRAL_API_KEY"
case .google: "GEMINI_API_KEY"
case .minimax: "MINIMAX_API_KEY"
case .minimaxCN: "MINIMAX_CN_API_KEY"
case .ollama: "OLLAMA_API_KEY"
case .lmstudio: "" // LMStudio doesn't need API keys
case .azureOpenAI: "AZURE_OPENAI_API_KEY"
@ -106,6 +118,7 @@ public enum Provider: Sendable, Hashable, Codable {
switch self {
case .grok: ["XAI_API_KEY", "GROK_API_KEY"] // Additional Grok aliases
case .google: ["GOOGLE_API_KEY"] // Backwards compatibility
case .minimaxCN: ["MINIMAX_API_KEY"]
case .azureOpenAI: ["AZURE_OPENAI_TOKEN", "AZURE_OPENAI_BEARER_TOKEN"]
default: []
}
@ -120,6 +133,8 @@ public enum Provider: Sendable, Hashable, Codable {
case .groq: "https://api.groq.com/openai/v1"
case .mistral: "https://api.mistral.ai/v1"
case .google: "https://generativelanguage.googleapis.com/v1beta"
case .minimax: "https://api.minimax.io/anthropic"
case .minimaxCN: "https://api.minimaxi.com/anthropic"
case .ollama: "http://localhost:11434"
case .lmstudio: "http://localhost:1234/v1"
case .azureOpenAI: nil // Requires resource or endpoint
@ -139,7 +154,7 @@ public enum Provider: Sendable, Hashable, Codable {
/// All standard providers (excludes custom)
public static var standardProviders: [Provider] {
[.openai, .anthropic, .grok, .groq, .mistral, .google, .ollama, .azureOpenAI]
[.openai, .anthropic, .grok, .groq, .mistral, .google, .minimax, .minimaxCN, .ollama, .azureOpenAI]
}
/// Create provider from string identifier
@ -151,7 +166,9 @@ public enum Provider: Sendable, Hashable, Codable {
case "grok", "xai": .grok
case "groq": .groq
case "mistral": .mistral
case "google": .google
case "google", "gemini": .google
case "minimax": .minimax
case "minimax-cn", "minimax_cn", "minimaxi": .minimaxCN
case "ollama": .ollama
case "azure-openai", "azure_openai", "azureopenai": .azureOpenAI
default: .custom(identifier)

View File

@ -56,7 +56,7 @@ public struct OpenAIOptions: Sendable, Codable {
/// Verbosity level for GPT-5 models
public var verbosity: Verbosity?
/// Reasoning effort for O3/O4 models
/// Reasoning effort for GPT-5 models
public var reasoningEffort: ReasoningEffort?
/// Previous response ID for Responses API chaining
@ -159,6 +159,7 @@ public struct AnthropicOptions: Sendable, Codable {
public enum ThinkingMode: Sendable, Codable {
case disabled
case enabled(budgetTokens: Int)
case adaptive
private enum CodingKeys: String, CodingKey {
case type
@ -174,6 +175,8 @@ public struct AnthropicOptions: Sendable, Codable {
case "enabled":
let budget = try container.decode(Int.self, forKey: .budgetTokens)
self = .enabled(budgetTokens: budget)
case "adaptive":
self = .adaptive
default:
throw DecodingError.dataCorruptedError(
forKey: .type,
@ -191,6 +194,8 @@ public struct AnthropicOptions: Sendable, Codable {
case let .enabled(budgetTokens):
try container.encode("enabled", forKey: .type)
try container.encode(budgetTokens, forKey: .budgetTokens)
case .adaptive:
try container.encode("adaptive", forKey: .type)
}
}
}

View File

@ -13,6 +13,17 @@ public protocol StopCondition: Sendable {
func reset() async
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
protocol StableCacheKeyStopCondition {
var stableCacheKey: String? { get }
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
private func compositeStableCacheKey(kind: String, children: [String]) -> String {
let encodedChildren = children.map { "\($0.utf8.count):\($0)" }.joined()
return "\(kind):[\(encodedChildren)]"
}
// MARK: - Built-in Stop Conditions
/// Stop when a specific string is encountered
@ -39,6 +50,13 @@ public struct StringStopCondition: StopCondition {
public func reset() async {}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension StringStopCondition: StableCacheKeyStopCondition {
var stableCacheKey: String? {
"string:\(self.caseSensitive):\(self.stopString)"
}
}
/// Stop when a regex pattern is matched
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct RegexStopCondition: StopCondition {
@ -82,6 +100,13 @@ public struct RegexStopCondition: StopCondition {
}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension RegexStopCondition: StableCacheKeyStopCondition {
var stableCacheKey: String? {
"regex:\(self.pattern)"
}
}
/// Stop after a certain number of tokens
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public actor TokenCountStopCondition: StopCondition {
@ -182,6 +207,15 @@ public struct AnyStopCondition: StopCondition {
}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension AnyStopCondition: StableCacheKeyStopCondition {
var stableCacheKey: String? {
let keys = self.conditions.compactMap { ($0 as? StableCacheKeyStopCondition)?.stableCacheKey }
guard keys.count == self.conditions.count else { return nil }
return compositeStableCacheKey(kind: "any", children: keys)
}
}
/// Stop when all conditions are met
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct AllStopCondition: StopCondition {
@ -211,6 +245,15 @@ public struct AllStopCondition: StopCondition {
}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension AllStopCondition: StableCacheKeyStopCondition {
var stableCacheKey: String? {
let keys = self.conditions.compactMap { ($0 as? StableCacheKeyStopCondition)?.stableCacheKey }
guard keys.count == self.conditions.count else { return nil }
return compositeStableCacheKey(kind: "all", children: keys)
}
}
// MARK: - Stateful Stop Conditions
/// Stop when a pattern appears consecutively N times
@ -386,6 +429,13 @@ public struct NeverStopCondition: StopCondition {
public func reset() async {}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
extension NeverStopCondition: StableCacheKeyStopCondition {
var stableCacheKey: String? {
"never"
}
}
// MARK: - Integration with Generation Functions
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
@ -431,9 +481,8 @@ extension AsyncThrowingStream where Element == TextStreamDelta {
// Check stop condition
if await condition.shouldStop(text: accumulatedText, delta: content) {
// Yield the current delta then stop
continuation.yield(delta)
continuation.yield(TextStreamDelta.done())
continuation.yield(TextStreamDelta.done(finishReason: .stop))
continuation.finish()
return
}

View File

@ -27,7 +27,7 @@ import Foundation
//
// ```swift
// // Simple generation
// let answer = try await generate("What is 2+2?", using: .openai(.gpt4o))
// let answer = try await generate("What is 2+2?", using: .openai(.gpt55))
//
// // Conversation management
// let conversation = Conversation()
@ -114,7 +114,7 @@ public enum API {
/// Model selection system
public enum Models {
/// Type-safe model selection
/// - `.openai(.gpt4o)`, `.anthropic(.opus4)`, `.grok(.grok4)`, `.ollama(.llama3_3)`
/// - `.openai(.gpt55)`, `.anthropic(.opus47)`, `.grok(.grok43)`, `.ollama(.llama3_3)`
public static let typed = "Provider-specific model enums"
/// Custom endpoints
@ -196,16 +196,16 @@ public enum API {
/// Migration guide from legacy API to modern API
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public enum MigrationGuide {
/// Legacy: `Tachikoma.shared.getModel("gpt-4").getResponse(request)`
/// Modern: `generate("Hello", using: .openai(.gpt4o))`
/// Legacy: `Tachikoma.shared.getModel("gpt-5.5").getResponse(request)`
/// Modern: `generate("Hello", using: .openai(.gpt55))`
public static let simpleGeneration = """
// OLD (deprecated)
let model = try await Tachikoma.shared.getModel("gpt-4")
let model = try await Tachikoma.shared.getModel("gpt-5.5")
let request = ModelRequest(messages: [.user(content: .text("Hello"))], settings: .default)
let response = try await model.getResponse(request: request)
// NEW (modern)
let response = try await generate("Hello", using: .openai(.gpt4o))
let response = try await generate("Hello", using: .openai(.gpt55))
"""
/// Legacy: Complex ModelRequest/ModelResponse handling

View File

@ -186,8 +186,9 @@ public enum OpenAIAPIMode: String, Sendable, CaseIterable {
public static func defaultMode(for model: LanguageModel.OpenAI) -> OpenAIAPIMode {
// Determine default API mode for a given model
switch model {
case .o4Mini, .gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt51, .gpt52:
.responses // Reasoning models and GPT-5 default to Responses API
case .chatLatest, .gpt5ChatLatest,
.gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt54, .gpt54Mini, .gpt54Nano, .gpt55:
.responses // GPT-5 defaults to Responses API
default:
.chat // All other models use Chat Completions API
}
@ -325,6 +326,11 @@ public enum ImageInput: Sendable {
/// Settings for text generation
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct GenerationSettings: Sendable {
public enum StreamBufferingMode: String, Sendable, Codable {
case incremental
case untilTerminal
}
public let maxTokens: Int?
public let temperature: Double?
public let topP: Double?
@ -336,6 +342,7 @@ public struct GenerationSettings: Sendable {
public let stopConditions: (any StopCondition)?
public let seed: Int?
public let providerOptions: ProviderOptions
public let streamBuffering: StreamBufferingMode
public init(
maxTokens: Int? = nil,
@ -349,6 +356,7 @@ public struct GenerationSettings: Sendable {
stopConditions: (any StopCondition)? = nil,
seed: Int? = nil,
providerOptions: ProviderOptions = .init(),
streamBuffering: StreamBufferingMode = .incremental,
) {
self.maxTokens = maxTokens
self.temperature = temperature
@ -361,9 +369,27 @@ public struct GenerationSettings: Sendable {
self.stopConditions = stopConditions
self.seed = seed
self.providerOptions = providerOptions
self.streamBuffering = streamBuffering
}
public static let `default` = GenerationSettings()
public func withStreamBuffering(_ mode: StreamBufferingMode) -> GenerationSettings {
GenerationSettings(
maxTokens: self.maxTokens,
temperature: self.temperature,
topP: self.topP,
topK: self.topK,
frequencyPenalty: self.frequencyPenalty,
presencePenalty: self.presencePenalty,
stopSequences: self.stopSequences,
reasoningEffort: self.reasoningEffort,
stopConditions: self.stopConditions,
seed: self.seed,
providerOptions: self.providerOptions,
streamBuffering: mode,
)
}
}
/// Manual Codable conformance excluding non-codable stopConditions
@ -379,6 +405,7 @@ extension GenerationSettings: Codable {
case reasoningEffort
case seed
case providerOptions
case streamBuffering
}
public init(from decoder: Decoder) throws {
@ -393,6 +420,8 @@ extension GenerationSettings: Codable {
self.reasoningEffort = try container.decodeIfPresent(ReasoningEffort.self, forKey: .reasoningEffort)
self.seed = try container.decodeIfPresent(Int.self, forKey: .seed)
self.providerOptions = try container.decodeIfPresent(ProviderOptions.self, forKey: .providerOptions) ?? .init()
self.streamBuffering = try container
.decodeIfPresent(StreamBufferingMode.self, forKey: .streamBuffering) ?? .incremental
self.stopConditions = nil // Can't decode function types
}
@ -408,6 +437,7 @@ extension GenerationSettings: Codable {
try container.encodeIfPresent(self.reasoningEffort, forKey: .reasoningEffort)
try container.encodeIfPresent(self.seed, forKey: .seed)
try container.encode(self.providerOptions, forKey: .providerOptions)
try container.encode(self.streamBuffering, forKey: .streamBuffering)
// Don't encode stopConditions since it can't be serialized
}
}
@ -614,7 +644,7 @@ public enum ResponseChannel: String, Sendable, Codable, CaseIterable {
case final // Final answer to the user
}
/// Reasoning effort level for models that support it (o3, opus-4, etc.)
/// Reasoning effort level for models that support it (GPT-5 thinking, opus-4, etc.)
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public enum ReasoningEffort: String, Sendable, Codable, CaseIterable {
case low

View File

@ -132,7 +132,9 @@ extension [ModelMessage] {
/// Convert model messages to UI messages for display
public func toUIMessages() -> [UIMessage] {
// Convert model messages to UI messages for display
map { modelMessage in
compactMap { modelMessage in
guard !modelMessage.isProviderNativeReasoningBlock else { return nil }
guard !modelMessage.isSyntheticReasoningBoundary else { return nil }
var content = ""
var attachments: [UIAttachment] = []
var toolCalls: [AgentToolCall] = []
@ -179,6 +181,20 @@ extension [ModelMessage] {
}
}
extension ModelMessage {
fileprivate var isProviderNativeReasoningBlock: Bool {
guard channel == .thinking, let customData = metadata?.customData else { return false }
return customData["anthropic.thinking.model"] != nil ||
customData["anthropic.thinking.type"] != nil ||
customData["anthropic.thinking.signature"] != nil ||
customData["tachikoma.reasoning.provider"] != nil
}
fileprivate var isSyntheticReasoningBoundary: Bool {
metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only"
}
}
// MARK: - Streaming Extensions
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -84,6 +84,27 @@ public struct ProviderResponse: Sendable {
public let usage: Usage?
public let finishReason: FinishReason?
public let toolCalls: [AgentToolCall]?
public let reasoning: [ProviderReasoningBlock]
public let assistantMessages: [ModelMessage]
public let isBillable: Bool
public init(
text: String,
usage: Usage? = nil,
finishReason: FinishReason? = nil,
toolCalls: [AgentToolCall]? = nil,
reasoning: [ProviderReasoningBlock] = [],
assistantMessages: [ModelMessage] = [],
isBillable: Bool = true,
) {
self.text = text
self.usage = usage
self.finishReason = finishReason
self.toolCalls = toolCalls
self.reasoning = reasoning
self.assistantMessages = assistantMessages
self.isBillable = isBillable
}
public init(
text: String,
@ -91,9 +112,30 @@ public struct ProviderResponse: Sendable {
finishReason: FinishReason? = nil,
toolCalls: [AgentToolCall]? = nil,
) {
self.text = text
self.usage = usage
self.finishReason = finishReason
self.toolCalls = toolCalls
self.init(
text: text,
usage: usage,
finishReason: finishReason,
toolCalls: toolCalls,
reasoning: [],
assistantMessages: [],
isBillable: true,
)
}
}
/// Provider-native signed reasoning block that must be replayed in later requests.
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct ProviderReasoningBlock: Sendable, Equatable {
public let text: String
public let signature: String?
public let type: String
public let rawJSON: String?
public init(text: String, signature: String? = nil, type: String = "thinking", rawJSON: String? = nil) {
self.text = text
self.signature = signature
self.type = type
self.rawJSON = rawJSON
}
}

View File

@ -30,12 +30,61 @@ public struct ModelSelector {
return .google(googleModel)
}
// MiniMax shortcuts and models
if let miniMaxModel = parseMiniMaxCNModel(normalized) {
return .minimaxCN(miniMaxModel)
}
if let miniMaxModel = parseMiniMaxModel(normalized) {
return .minimax(miniMaxModel)
}
// Grok shortcuts and models
if let grokModel = parseGrokModel(normalized) {
return .grok(grokModel)
}
// Ollama shortcuts and models
if
let qualified = ProviderParser.parse(normalized),
["grok", "xai", "x-ai"].contains(qualified.provider.lowercased())
{
let model = qualified.model.lowercased()
guard
!Self.isUnsupportedLegacyGrokModel(model),
let grokModel = self.parseGrokModel(model) else
{
throw ModelValidationError.unsupportedModel(modelString)
}
return .grok(grokModel)
}
if
Self.isUnsupportedLegacyOpenAIModel(normalized) ||
Self.isUnsupportedLegacyAnthropicModel(normalized) ||
Self.isUnsupportedLegacyGrokModel(normalized)
{
throw ModelValidationError.unsupportedModel(modelString)
}
// LM Studio shortcuts and models
if let lmStudioModel = parseLMStudioModel(normalized) {
return .lmstudio(lmStudioModel)
}
if let qualified = ProviderParser.parse(normalized) {
let provider = qualified.provider.lowercased()
if provider == "openai", let openaiModel = parseOpenAIModel(qualified.model.lowercased()) {
return .openai(openaiModel)
}
if provider == "openrouter" {
return .openRouter(modelId: qualified.model)
}
if !["ollama", "lmstudio", "lm-studio"].contains(provider) {
return .openRouter(modelId: normalized)
}
}
// Ollama shortcuts and models. Keep after explicit local providers because it accepts custom IDs.
if let ollamaModel = parseOllamaModel(normalized) {
return .ollama(ollamaModel)
}
@ -46,7 +95,7 @@ public struct ModelSelector {
}
// Custom model ID - try to infer provider
if normalized.contains("gpt") || normalized.contains("o3") || normalized.contains("o4") {
if normalized.contains("gpt-5") || normalized.contains("gpt5") {
return .openai(.custom(normalized))
}
@ -66,20 +115,24 @@ public struct ModelSelector {
private static func parseOpenAIModel(_ input: String) -> Model.OpenAI? {
switch input {
// GPT-5.2 models
case "gpt-5.2", "gpt5.2", "gpt-5-2", "gpt5-2", "gpt52":
return .gpt52
case "gpt-5.2-mini", "gpt5.2-mini", "gpt52-mini", "gpt52mini", "gpt-5-2-mini", "gpt5-2-mini":
case "chat-latest", "chatlatest":
return .chatLatest
case "gpt-5-chat-latest", "gpt5-chat-latest", "gpt5chatlatest":
return .gpt5ChatLatest
// GPT-5.5 models
case "gpt-5.5", "gpt5.5", "gpt-5-5", "gpt5-5", "gpt55":
return .gpt55
case "gpt-5.5-mini", "gpt5.5-mini", "gpt55-mini", "gpt55mini", "gpt-5-5-mini", "gpt5-5-mini":
return .gpt5Mini
case "gpt-5.2-nano", "gpt5.2-nano", "gpt52-nano", "gpt52nano", "gpt-5-2-nano", "gpt5-2-nano":
return .gpt5Nano
// GPT-5.1 models (latest)
case "gpt-5.1", "gpt5.1", "gpt-5-1", "gpt5-1", "gpt51":
return .gpt51
case "gpt-5.1-mini", "gpt5.1-mini", "gpt51-mini", "gpt51mini", "gpt-5-1-mini", "gpt5-1-mini":
return .gpt5Mini
case "gpt-5.1-nano", "gpt5.1-nano", "gpt51-nano", "gpt51nano", "gpt-5-1-nano", "gpt5-1-nano":
case "gpt-5.5-nano", "gpt5.5-nano", "gpt55-nano", "gpt55nano", "gpt-5-5-nano", "gpt5-5-nano":
return .gpt5Nano
// GPT-5.4 models
case "gpt-5.4", "gpt5.4", "gpt-5-4", "gpt5-4", "gpt54":
return .gpt54
case "gpt-5.4-mini", "gpt5.4-mini", "gpt54-mini", "gpt54mini", "gpt-5-4-mini", "gpt5-4-mini":
return .gpt54Mini
case "gpt-5.4-nano", "gpt5.4-nano", "gpt54-nano", "gpt54nano", "gpt-5-4-nano", "gpt5-4-nano":
return .gpt54Nano
// GPT-5 models
case "gpt-5", "gpt5":
return .gpt5
@ -89,35 +142,17 @@ public struct ModelSelector {
return .gpt5Mini
case "gpt-5-nano", "gpt5-nano", "gpt5nano":
return .gpt5Nano
case "gpt-5-thinking", "gpt5-thinking", "gpt5thinking":
return .gpt5Thinking
case "gpt-5-thinking-mini", "gpt5-thinking-mini", "gpt5thinkingmini":
return .gpt5ThinkingMini
case "gpt-5-thinking-nano", "gpt5-thinking-nano", "gpt5thinkingnano":
return .gpt5ThinkingNano
case "gpt-5-chat-latest", "gpt5-chat-latest":
return .gpt5ChatLatest
// Direct matches
case "gpt-4o", "gpt4o":
return .gpt4o
case "gpt-4o-mini", "gpt4o-mini", "gpt4omini":
return .gpt4oMini
case "gpt-4.1", "gpt4.1", "gpt41":
return .gpt41
case "gpt-4.1-mini", "gpt4.1-mini", "gpt41mini":
return .gpt41Mini
case "o4-mini", "o4mini":
return .o4Mini
// Shortcuts
case "gpt":
return .gpt51 // Default to flagship GPT-5.1
case "gpt4", "gpt-4":
return .gpt4o // Default to latest GPT-4 variant
return .gpt55 // Default to flagship GPT-5.5
case "openai":
return .gpt51 // Default to GPT-5.1
return .gpt55 // Default to GPT-5.5
default:
// Check if it's an OpenAI model ID
if input.hasPrefix("gpt") || input.hasPrefix("o4") {
if self.isUnsupportedLegacyOpenAIModel(input) {
return nil
}
if input.hasPrefix("gpt-5") || input.hasPrefix("gpt5") {
return .custom(input)
}
return nil
@ -127,31 +162,35 @@ public struct ModelSelector {
private static func parseAnthropicModel(_ input: String) -> Model.Anthropic? {
switch input {
// Direct matches
case "claude-opus-4-20250514":
return .opus4
case "claude-opus-4-20250514-thinking":
return .opus4Thinking
case "claude-fable-5", "claude-fable-5-latest", "fable-5", "fable.5", "fable5", "fable":
return .fable5
case "claude-opus-4-8", "claude-opus-4.8", "opus-4-8", "opus-4.8", "opus48",
"claude-opus-4-8-latest":
return .opus48
case "claude-opus-4-7", "claude-opus-4.7", "opus-4-7", "opus-4.7", "opus47":
return .opus47
case "claude-opus-4-5", "claude-opus-4.5", "opus-4-5", "opus-4.5", "opus45":
return .opus45
case "claude-sonnet-4-20250514":
return .sonnet4
case "claude-sonnet-4-20250514-thinking":
return .sonnet4Thinking
case "claude-sonnet-4-6", "claude-sonnet-4.6", "sonnet-4-6", "sonnet-4.6", "sonnet46":
return .sonnet46
case "claude-sonnet-4-5-20250929", "claude-sonnet-4.5":
return .sonnet45
// Shortcuts
case "claude":
return .sonnet45 // Default plain Claude alias to latest Sonnet
return .opus48
case "claude-opus", "opus":
return .opus45
return .opus48
case "claude-sonnet", "sonnet":
return .sonnet4
return .sonnet46
case "claude-haiku", "haiku":
return .haiku45
case "anthropic":
return .opus45 // Default Anthropic model
return .opus48
default:
// Check if it's a Claude model ID
if self.isUnsupportedLegacyAnthropicModel(input) {
return nil
}
if input.hasPrefix("claude") {
return .custom(input)
}
@ -159,8 +198,34 @@ public struct ModelSelector {
}
}
private static func isUnsupportedLegacyOpenAIModel(_ input: String) -> Bool {
let normalized = input.lowercased()
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
return normalized.hasPrefix("gpt-4") || compact.hasPrefix("gpt4") ||
normalized.hasPrefix("gpt-3") || compact.hasPrefix("gpt3") ||
normalized.hasPrefix("o3") || normalized.hasPrefix("o4") ||
normalized.hasPrefix("gpt-5.1") || normalized.hasPrefix("gpt-5.2") ||
compact.hasPrefix("gpt51") || compact.hasPrefix("gpt52") ||
normalized.contains("gpt-5-thinking") || compact.contains("gpt5thinking")
}
private static func isUnsupportedLegacyAnthropicModel(_ input: String) -> Bool {
let normalized = input.lowercased()
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
return normalized.hasPrefix("claude-3") || compact.hasPrefix("claude3") ||
normalized == "claude-opus-4-20250514" ||
normalized == "claude-sonnet-4-20250514" ||
normalized.contains("-thinking")
}
private static func parseGoogleModel(_ input: String) -> Model.Google? {
switch input {
case "gemini-3.5-flash", "gemini35flash", "gemini-3-5-flash":
.gemini35Flash
case "gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini31pro", "gemini31propreview":
.gemini31ProPreview
case "gemini-3.1-flash-lite", "gemini31flashlite", "gemini-3.1-flashlite":
.gemini31FlashLite
case "gemini-3-flash", "gemini-3-flash-preview", "gemini3flash", "gemini-3flash":
.gemini3Flash
case "gemini-2.5-pro", "gemini25pro", "gemini2.5pro":
@ -170,9 +235,9 @@ public struct ModelSelector {
case "gemini-2.5-flash-lite", "gemini25flashlite", "gemini-2.5-flashlite":
.gemini25FlashLite
case "gemini":
.gemini3Flash
.gemini35Flash
case "google":
.gemini25Pro
.gemini35Flash
default:
nil
}
@ -181,35 +246,22 @@ public struct ModelSelector {
private static func parseGrokModel(_ input: String) -> Model.Grok? {
switch input {
// Direct matches for available models only
case "grok-4-0709":
return .grok4
case "grok-4-fast-reasoning":
return .grok4FastReasoning
case "grok-4-fast-non-reasoning":
return .grok4FastNonReasoning
case "grok-code-fast-1":
return .grokCodeFast1
case "grok-3", "grok3":
return .grok3
case "grok-3-mini":
return .grok3Mini
case "grok-2-1212", "grok-2":
return .grok2
case "grok-2-vision-1212":
return .grok2Vision
case "grok-2-image-1212":
return .grok2Image
case "grok-vision-beta":
return .grokVisionBeta
case "grok-beta":
return .grokBeta
case "grok-4.3", "grok-4-3", "grok43", "grok-4.3-latest", "grok-4-latest", "grok-4", "grok-latest":
return .grok43
case "grok-4.20-0309-reasoning", "grok-4-20-0309-reasoning":
return .grok420Reasoning
case "grok-4.20-0309-non-reasoning", "grok-4-20-0309-non-reasoning":
return .grok420NonReasoning
// Shortcuts
case "grok":
return .grok4FastReasoning // Default to the latest fast Grok model
return .grok43
case "xai":
return .grok3 // Default xAI model
return .grok43
default:
// Check if it's a Grok model ID
if self.isUnsupportedLegacyGrokModel(input) {
return nil
}
if input.hasPrefix("grok") {
return .custom(input)
}
@ -217,6 +269,51 @@ public struct ModelSelector {
}
}
private static func parseMiniMaxModel(_ input: String) -> Model.MiniMax? {
switch input {
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7", "minimax/m2.7", "minimax/m2-7",
"minimax/minimax-m2.7", "minimax/minimax-m2-7":
.m27
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed",
"minimax/m2.7-highspeed", "minimax/m2-7-highspeed", "minimax/minimax-m2.7-highspeed",
"minimax/minimax-m2-7-highspeed":
.m27Highspeed
case "minimax":
.m27
default:
nil
}
}
private static func parseMiniMaxCNModel(_ input: String) -> Model.MiniMax? {
switch input {
case "minimax-cn-m2.7", "minimax-cn-m2-7", "minimaxi-m2.7", "minimaxi-m2-7",
"minimax_cn/m2.7", "minimax_cn/m2-7", "minimax_cn/minimax-m2.7",
"minimax_cn/minimax-m2-7",
"minimax-cn/m2.7", "minimax-cn/m2-7", "minimax-cn/minimax-m2.7",
"minimax-cn/minimax-m2-7", "minimaxi/m2.7", "minimaxi/m2-7":
.m27
case "minimax-cn-m2.7-highspeed", "minimax-cn-m2-7-highspeed", "minimaxi-m2.7-highspeed",
"minimaxi-m2-7-highspeed", "minimax-cn/m2.7-highspeed", "minimax-cn/m2-7-highspeed",
"minimax_cn/m2.7-highspeed", "minimax_cn/m2-7-highspeed",
"minimax_cn/minimax-m2.7-highspeed", "minimax_cn/minimax-m2-7-highspeed",
"minimax-cn/minimax-m2.7-highspeed", "minimax-cn/minimax-m2-7-highspeed",
"minimaxi/m2.7-highspeed", "minimaxi/m2-7-highspeed":
.m27Highspeed
case "minimax-cn", "minimax_cn", "minimaxi":
.m27
default:
nil
}
}
private static func isUnsupportedLegacyGrokModel(_ input: String) -> Bool {
let normalized = input.lowercased()
return normalized.contains("grok-4.20-multi-agent") ||
normalized.contains("grok-4-20-multi-agent") ||
normalized.contains("grok420multiagent")
}
private static func parseOllamaModel(_ input: String) -> Model.Ollama? {
switch input {
// Direct matches
@ -238,22 +335,12 @@ public struct ModelSelector {
.qwen25vl7b
case "qwen2.5vl:32b":
.qwen25vl32b
case "llama2", "llama2:latest":
.llama2
case "llama4", "llama4:latest":
.llama4
case "codellama", "codellama:latest":
.codellama
case "mistral", "mistral:latest":
.mistral
case "mistral-nemo", "mistral-nemo:latest":
.mistralNemo
case "mixtral", "mixtral:latest":
.mixtral
case "neural-chat", "neural-chat:latest":
.neuralChat
case "gemma", "gemma:latest":
.gemma
case "devstral", "devstral:latest":
.devstral
case "deepseek-r1:8b":
@ -277,6 +364,33 @@ public struct ModelSelector {
}
}
private static func parseLMStudioModel(_ input: String) -> Model.LMStudio? {
if input == "lmstudio" || input == "lm-studio" {
return .gptOSS120B
}
for prefix in ["lmstudio/", "lm-studio/"] where input.hasPrefix(prefix) {
let modelId = String(input.dropFirst(prefix.count))
guard !modelId.isEmpty else { return nil }
return Self.parseLMStudioModelIdentifier(modelId)
}
return nil
}
private static func parseLMStudioModelIdentifier(_ input: String) -> Model.LMStudio {
switch input {
case "openai/gpt-oss-120b", "gpt-oss-120b", "gpt-oss:120b":
.gptOSS120B
case "openai/gpt-oss-20b", "gpt-oss-20b", "gpt-oss:20b":
.gptOSS20B
case "meta/llama-3.3-70b", "llama-3.3-70b", "llama3.3-70b":
.llama3370B
default:
.custom(input)
}
}
// MARK: - Model Information
/// Get available models for a specific provider
@ -302,11 +416,18 @@ public struct ModelSelector {
}
case "google", "gemini":
return Model.Google.allCases.map(\.userFacingModelId)
case "minimax", "minimax-cn", "minimaxi":
return Model.MiniMax.allCases.map(\.modelId)
case "ollama":
return Model.Ollama.allCases.compactMap {
if case .custom = $0 { return nil }
return $0.modelId
}
case "lmstudio", "lm-studio":
return Model.LMStudio.allCases.compactMap {
if case .custom = $0 { return nil }
return $0.modelId
}
default:
return []
}
@ -377,6 +498,16 @@ public func getAllAvailableModels() -> String {
models: ModelSelector.availableModels(for: "google"),
)
output += formatModelList(
title: "MiniMax",
models: ModelSelector.availableModels(for: "minimax"),
)
output += formatModelList(
title: "MiniMax China",
models: ModelSelector.availableModels(for: "minimax-cn"),
)
output += formatModelList(
title: "Grok (xAI)",
models: ModelSelector.availableModels(for: "grok"),
@ -388,14 +519,17 @@ public func getAllAvailableModels() -> String {
)
output += "\nShortcuts:\n"
output += " • claude, claude-opus, opus → claude-opus-4-20250514\n"
output += " • gpt, gpt4 → gpt-4.1\n"
output += " • gemini → gemini-3-flash\n"
output += " • grok → grok-4-fast-reasoning\n"
output += " • claude, claude-opus, opus → claude-opus-4-8\n"
output += " • fable → claude-fable-5\n"
output += " • gpt → gpt-5.5\n"
output += " • gemini → gemini-3.5-flash\n"
output += " • minimax → MiniMax-M2.7\n"
output += " • minimax-cn → MiniMax-M2.7 via api.minimaxi.com\n"
output += " • grok → grok-4.3\n"
output += " • llama, llama3 → llama3.3\n"
output += "\nCustom Models:\n"
output += " • OpenRouter: anthropic/claude-3.5-sonnet\n"
output += " • OpenRouter: anthropic/claude-fable-5\n"
output += " • Custom OpenAI: custom-gpt-model\n"
output += " • Local Ollama: any-model:tag\n"
@ -423,15 +557,15 @@ extension ModelSelector {
// Get recommended models for specific use cases
switch useCase {
case .coding:
[.claude, .gpt4o, .google(.gemini25Pro)]
[.claude, .openai(.gpt55), .google(.gemini35Flash)]
case .vision:
[.claude, .gpt4o, .google(.gemini3Flash)]
[.claude, .openai(.gpt55), .google(.gemini35Flash)]
case .reasoning:
[.openai(.gpt5Mini), .claude, .google(.gemini25Pro)]
[.openai(.gpt54), .claude, .google(.gemini35Flash)]
case .local:
[.llama, .ollama(.mistralNemo), .ollama(.commandRPlus)]
case .general:
[.claude, .gpt4o, .google(.gemini3Flash), .grok(.grok4FastReasoning), .llama]
[.claude, .openai(.gpt55), .google(.gemini35Flash), .grok(.grok43), .llama]
}
}
}
@ -449,6 +583,7 @@ public enum UseCase {
public enum ModelValidationError: Error, LocalizedError {
case visionNotSupported(String)
case toolsNotSupported(String)
case unsupportedModel(String)
public var errorDescription: String? {
switch self {
@ -456,6 +591,8 @@ public enum ModelValidationError: Error, LocalizedError {
"Model '\(modelId)' does not support vision inputs"
case let .toolsNotSupported(modelId):
"Model '\(modelId)' does not support tool calling"
case let .unsupportedModel(modelId):
"Model '\(modelId)' is no longer supported"
}
}
}

View File

@ -89,11 +89,13 @@ struct AnthropicMessageRequest: Codable {
let messages: [AnthropicMessage]
let tools: [AnthropicTool]?
let thinking: AnthropicThinking?
let outputConfig: AnthropicOutputConfig?
let stream: Bool?
enum CodingKeys: String, CodingKey {
case model, temperature, system, messages, tools, thinking, stream
case maxTokens = "max_tokens"
case outputConfig = "output_config"
}
}
@ -107,6 +109,10 @@ struct AnthropicThinking: Codable {
}
}
struct AnthropicOutputConfig: Codable {
let effort: String?
}
struct AnthropicMessage: Codable {
let role: String
let content: [AnthropicContent]
@ -133,13 +139,11 @@ enum AnthropicContent: Codable {
struct RedactedThinkingContent: Codable {
let type: String
let redactedThinking: String
let signature: String
let data: String
enum CodingKeys: String, CodingKey {
case type
case redactedThinking = "redacted_thinking"
case signature
case data
}
}
@ -396,12 +400,14 @@ struct AnthropicMessageResponse: Codable {
let model: String
let stopReason: String?
let stopSequence: String?
let stopDetails: StopDetails?
let usage: AnthropicUsage
enum CodingKeys: String, CodingKey {
case id, type, role, content, model, usage
case stopReason = "stop_reason"
case stopSequence = "stop_sequence"
case stopDetails = "stop_details"
}
init(from decoder: Decoder) throws {
@ -413,12 +419,20 @@ struct AnthropicMessageResponse: Codable {
self.model = try container.decode(String.self, forKey: .model)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
self.stopSequence = try container.decodeIfPresent(String.self, forKey: .stopSequence)
self.stopDetails = try container.decodeIfPresent(StopDetails.self, forKey: .stopDetails)
self.usage = try container.decode(AnthropicUsage.self, forKey: .usage)
}
struct StopDetails: Codable {
let category: String?
let explanation: String?
}
}
enum AnthropicResponseContent: Codable {
case text(TextContent)
case thinking(ThinkingContent)
case redactedThinking(RedactedThinkingContent)
case toolUse(ToolUseContent)
struct TextContent: Codable {
@ -455,6 +469,22 @@ enum AnthropicResponseContent: Codable {
}
}
struct ThinkingContent: Codable {
let type: String
let thinking: String
let signature: String
}
struct RedactedThinkingContent: Codable {
let type: String
let data: String
enum CodingKeys: String, CodingKey {
case type
case data
}
}
struct ToolUseContent: Codable {
let type: String
let id: String
@ -534,6 +564,10 @@ enum AnthropicResponseContent: Codable {
switch type {
case "text":
self = try .text(TextContent(from: decoder))
case "thinking":
self = try .thinking(ThinkingContent(from: decoder))
case "redacted_thinking":
self = try .redactedThinking(RedactedThinkingContent(from: decoder))
case "tool_use":
self = try .toolUse(ToolUseContent(from: decoder))
default:
@ -552,6 +586,10 @@ enum AnthropicResponseContent: Codable {
switch self {
case let .text(content):
try content.encode(to: encoder)
case let .thinking(content):
try content.encode(to: encoder)
case let .redactedThinking(content):
try content.encode(to: encoder)
case let .toolUse(content):
try content.encode(to: encoder)
}
@ -561,6 +599,8 @@ enum AnthropicResponseContent: Codable {
case type
case text
case thinking
case redactedThinking = "redacted_thinking"
case signature
}
}
@ -572,6 +612,17 @@ struct AnthropicUsage: Codable {
case inputTokens = "input_tokens"
case outputTokens = "output_tokens"
}
init(inputTokens: Int, outputTokens: Int) {
self.inputTokens = inputTokens
self.outputTokens = outputTokens
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0
self.outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0
}
}
// MARK: - Streaming Types
@ -606,12 +657,11 @@ struct AnthropicStreamContentBlock: Codable {
let text: String?
let input: Any?
let thinking: String?
let redactedThinking: String?
let data: String?
let signature: String?
enum CodingKeys: String, CodingKey {
case type, id, name, text, input, thinking, signature
case redactedThinking = "redacted_thinking"
case type, id, name, text, input, thinking, data, signature
}
init(from decoder: Decoder) throws {
@ -621,7 +671,7 @@ struct AnthropicStreamContentBlock: Codable {
self.name = try? container.decode(String.self, forKey: .name)
self.text = try? container.decode(String.self, forKey: .text)
self.thinking = try? container.decode(String.self, forKey: .thinking)
self.redactedThinking = try? container.decode(String.self, forKey: .redactedThinking)
self.data = try? container.decode(String.self, forKey: .data)
self.signature = try? container.decode(String.self, forKey: .signature)
// Decode input as generic JSON if present
@ -647,7 +697,7 @@ struct AnthropicStreamContentBlock: Codable {
try container.encodeIfPresent(self.name, forKey: .name)
try container.encodeIfPresent(self.text, forKey: .text)
try container.encodeIfPresent(self.thinking, forKey: .thinking)
try container.encodeIfPresent(self.redactedThinking, forKey: .redactedThinking)
try container.encodeIfPresent(self.data, forKey: .data)
try container.encodeIfPresent(self.signature, forKey: .signature)
if let input {
let data = try JSONSerialization.data(withJSONObject: input)
@ -671,6 +721,17 @@ struct AnthropicStreamDelta: Codable {
case stopReason = "stop_reason"
case stopSequence = "stop_sequence"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
self.text = try container.decodeIfPresent(String.self, forKey: .text)
self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking)
self.signature = try container.decodeIfPresent(String.self, forKey: .signature)
self.partialJson = try container.decodeIfPresent(String.self, forKey: .partialJson)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
self.stopSequence = try container.decodeIfPresent(String.self, forKey: .stopSequence)
}
}
struct AnthropicErrorResponse: Codable {

View File

@ -6,32 +6,79 @@ public final class AnthropicCompatibleProvider: ModelProvider {
public let modelId: String
public let baseURL: String?
public let apiKey: String?
public let additionalHeaders: [String: String]
public let capabilities: ModelCapabilities
private let configuration: TachikomaConfiguration
private let auth: TKAuthValue?
private let reasoningProvider: String
private let reasoningModelId: String
private let reasoningBaseURL: String?
public init(modelId: String, baseURL: String, configuration: TachikomaConfiguration) throws {
public init(
modelId: String,
baseURL: String,
configuration: TachikomaConfiguration,
apiKey: String? = nil,
additionalHeaders: [String: String] = [:],
auth: TKAuthValue? = nil,
capabilities: ModelCapabilities? = nil,
reasoningProvider: String = "anthropic-compatible",
reasoningModelId: String? = nil,
reasoningBaseURL: String? = nil,
includeReasoningBaseURL: Bool = true,
) throws {
self.modelId = modelId
self.baseURL = baseURL
self.configuration = configuration
self.additionalHeaders = additionalHeaders
self.reasoningProvider = reasoningProvider
self.reasoningModelId = reasoningModelId ?? modelId
self.reasoningBaseURL = includeReasoningBaseURL ? (reasoningBaseURL ?? baseURL) : nil
// Try to get API key from configuration, otherwise try common environment variable patterns
if let key = configuration.getAPIKey(for: .custom("anthropic_compatible")) {
// Try explicit provider key, then configuration, then common environment variable patterns.
if let key = apiKey {
self.apiKey = key
self.auth = auth ?? .apiKey(key)
} else if let key = configuration.getAPIKey(for: .custom("anthropic_compatible")) {
self.apiKey = key
self.auth = auth ?? .apiKey(key)
} else if
let key = ProcessInfo.processInfo.environment["ANTHROPIC_COMPATIBLE_API_KEY"] ??
ProcessInfo.processInfo.environment["API_KEY"]
{
self.apiKey = key
self.auth = auth ?? .apiKey(key)
} else if let auth {
self.auth = auth
switch auth {
case let .apiKey(key):
self.apiKey = key
case let .bearer(token, _):
self.apiKey = token
}
} else {
self.apiKey = nil
self.auth = nil
}
self.capabilities = ModelCapabilities(
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
let supportsSafeStreaming = !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
let baseCapabilities = capabilities ?? ModelCapabilities(
supportsVision: true,
supportsTools: true,
supportsStreaming: true,
contextLength: 200_000,
maxOutputTokens: 8192,
supportsStreaming: supportsSafeStreaming,
contextLength: isFable ? 1_000_000 : 200_000,
maxOutputTokens: isFable ? 128_000 : 8192,
)
self.capabilities = supportsSafeStreaming ? baseCapabilities : ModelCapabilities(
supportsVision: baseCapabilities.supportsVision,
supportsTools: baseCapabilities.supportsTools,
supportsStreaming: false,
supportsAudioInput: baseCapabilities.supportsAudioInput,
supportsAudioOutput: baseCapabilities.supportsAudioOutput,
contextLength: baseCapabilities.contextLength,
maxOutputTokens: baseCapabilities.maxOutputTokens,
costPerToken: baseCapabilities.costPerToken,
)
}
@ -62,6 +109,11 @@ public final class AnthropicCompatibleProvider: ModelProvider {
return try AnthropicProvider(
model: .custom(self.modelId),
configuration: compatConfig,
additionalHeaders: self.additionalHeaders,
authOverride: self.auth,
reasoningProvider: self.reasoningProvider,
reasoningModelId: self.reasoningModelId,
reasoningBaseURL: self.reasoningBaseURL,
)
}
}

View File

@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Provider for OpenAI-compatible APIs
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
@ -6,14 +9,27 @@ public final class OpenAICompatibleProvider: ModelProvider {
public let modelId: String
public let baseURL: String?
public let apiKey: String?
public let additionalHeaders: [String: String]
public let capabilities: ModelCapabilities
private let session: URLSession
public init(modelId: String, baseURL: String, configuration: TachikomaConfiguration) throws {
public init(
modelId: String,
baseURL: String,
configuration: TachikomaConfiguration,
apiKey: String? = nil,
additionalHeaders: [String: String] = [:],
session: URLSession = .shared,
) throws {
self.modelId = modelId
self.baseURL = baseURL
self.additionalHeaders = additionalHeaders
self.session = session
// Try to get API key from configuration, otherwise try common environment variable patterns
if let key = configuration.getAPIKey(for: .custom("openai_compatible")) {
// Try explicit provider key, then configuration, then common environment variable patterns.
if let key = apiKey {
self.apiKey = key
} else if let key = configuration.getAPIKey(for: .custom("openai_compatible")) {
self.apiKey = key
} else if
let key = ProcessInfo.processInfo.environment["OPENAI_COMPATIBLE_API_KEY"] ??
@ -24,12 +40,13 @@ public final class OpenAICompatibleProvider: ModelProvider {
self.apiKey = nil // Some compatible APIs don't require keys
}
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
self.capabilities = ModelCapabilities(
supportsVision: false,
supportsTools: true,
supportsStreaming: true,
contextLength: 128_000,
maxOutputTokens: 4096,
supportsStreaming: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
contextLength: isFable ? 1_000_000 : 128_000,
maxOutputTokens: isFable ? 128_000 : 4096,
)
}
@ -41,17 +58,25 @@ public final class OpenAICompatibleProvider: ModelProvider {
baseURL: self.baseURL!,
apiKey: self.apiKey ?? "",
providerName: "OpenAICompatible",
additionalHeaders: self.additionalHeaders,
session: self.session,
)
}
public func streamText(request: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: self.modelId) else {
throw TachikomaError.invalidConfiguration("\(self.modelId) does not support streaming")
}
// Use OpenAI-compatible streaming implementation
try await OpenAICompatibleHelper.streamText(
return try await OpenAICompatibleHelper.streamText(
request: request,
modelId: self.modelId,
baseURL: self.baseURL!,
apiKey: self.apiKey ?? "",
providerName: "OpenAICompatible",
additionalHeaders: self.additionalHeaders,
session: self.session,
)
}
}

View File

@ -214,16 +214,15 @@ public final class GoogleProvider: ModelProvider {
}
}
guard !parts.isEmpty else { continue }
let role = switch message.role {
case .assistant:
"model"
case .tool:
"function"
"user"
default:
"user"
}
contents.append(.init(role: role, parts: parts))
Self.appendGoogleContent(role: role, parts: parts, to: &contents)
}
let config = GoogleGenerateRequest.GenerationConfig(
@ -253,6 +252,20 @@ public final class GoogleProvider: ModelProvider {
// MARK: - Streaming Helpers
extension GoogleProvider {
private static func appendGoogleContent(
role: String,
parts: [GoogleGenerateRequest.Content.Part],
to contents: inout [GoogleGenerateRequest.Content],
) {
guard !parts.isEmpty else { return }
if let last = contents.last, last.role == role {
contents[contents.count - 1] = .init(role: role, parts: last.parts + parts)
} else {
contents.append(.init(role: role, parts: parts))
}
}
private static func buildToolCallNameMap(from messages: [ModelMessage]) -> [String: String] {
var map: [String: String] = [:]
for message in messages {
@ -271,12 +284,6 @@ extension GoogleProvider {
}
private static func convertTool(_ tool: AgentTool) throws -> GoogleGenerateRequest.Tool.FunctionDeclaration {
var parameters: [String: Any] = [
"type": "object",
"properties": [:],
"required": tool.parameters.required,
]
var properties: [String: Any] = [:]
for (key, prop) in tool.parameters.properties {
var propDict: [String: Any] = [
@ -295,7 +302,21 @@ extension GoogleProvider {
}
properties[key] = propDict
}
parameters["properties"] = properties
// Gemini validates that every name in `required` exists in `properties`. Tools occasionally
// ship with a `required` entry whose property got filtered out during 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
}
guard let schema = JSONValue(value: parameters) else {
throw TachikomaError.invalidInput("Failed to encode tool parameters for '\(tool.name)'")

View File

@ -11,8 +11,15 @@ public final class GrokProvider: ModelProvider {
private let model: LanguageModel.Grok
public init(model: LanguageModel.Grok, configuration: TachikomaConfiguration) throws {
let modelId = model.modelId
guard !Self.requiresResponsesAPIRouting(modelId) else {
throw TachikomaError.unsupportedOperation(
"\(modelId) requires xAI Responses API routing",
)
}
self.model = model
self.modelId = model.modelId
self.modelId = modelId
self.baseURL = configuration.getBaseURL(for: .grok) ?? "https://api.x.ai/v1"
// Get API key from configuration system (environment or credentials)
@ -31,6 +38,16 @@ public final class GrokProvider: ModelProvider {
)
}
private static func requiresResponsesAPIRouting(_ modelId: String) -> Bool {
let normalized = modelId.lowercased()
let compact = normalized
.replacingOccurrences(of: "-", with: "")
.replacingOccurrences(of: ".", with: "")
return normalized.contains("grok-4.20-multi-agent") ||
normalized.contains("grok-4-20-multi-agent") ||
compact.contains("grok420multiagent")
}
public func generateText(request: ProviderRequest) async throws -> ProviderResponse {
// Grok uses OpenAI-compatible API format - delegate to shared implementation
try await OpenAICompatibleHelper.generateText(

View File

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

View File

@ -20,6 +20,10 @@ public final class OpenAIProvider: ModelProvider {
configuration: TachikomaConfiguration,
session: URLSession = .shared,
) throws {
guard !model.isUnsupportedLegacyFamily else {
throw TachikomaError.unsupportedOperation("OpenAI model '\(model.modelId)' is no longer supported")
}
self.model = model
self.modelId = model.modelId
self.baseURL = configuration.getBaseURL(for: .openai) ?? "https://api.openai.com/v1"

View File

@ -2,11 +2,8 @@ import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Provider for OpenAI Responses API (GPT-5, o3, o4)
/// Provider for OpenAI Responses API (GPT-5)
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public final class OpenAIResponsesProvider: ModelProvider {
public let modelId: String
@ -17,6 +14,7 @@ public final class OpenAIResponsesProvider: ModelProvider {
private let model: LanguageModel.OpenAI
private let configuration: TachikomaConfiguration
private let session: URLSession
private let auth: TKAuthValue
private static let debugLogURL = URL(fileURLWithPath: "/tmp/tachikoma-gpt5.log")
@ -31,15 +29,28 @@ public final class OpenAIResponsesProvider: ModelProvider {
configuration: TachikomaConfiguration,
session: URLSession = .shared,
) throws {
guard !model.isUnsupportedLegacyFamily else {
throw TachikomaError.unsupportedOperation("OpenAI model '\(model.modelId)' is no longer supported")
}
self.model = model
self.modelId = model.modelId
self.configuration = configuration
self.session = session
self.baseURL = configuration.getBaseURL(for: .openai) ?? "https://api.openai.com/v1"
// Get API key from configuration
// Prefer configuration-provided key first (test configs use this), then shared OAuth/API-key auth.
if let key = configuration.getAPIKey(for: .openai) {
self.auth = .bearer(key, betaHeader: nil)
self.apiKey = key
} else if let auth = TKAuthManager.shared.resolveAuth(for: .openai) {
self.auth = auth
switch auth {
case let .apiKey(key):
self.apiKey = key
case let .bearer(token, _):
self.apiKey = token
}
} else {
throw TachikomaError.authenticationFailed("OPENAI_API_KEY not found")
}
@ -47,13 +58,15 @@ public final class OpenAIResponsesProvider: ModelProvider {
// Set capabilities based on model
let isReasoningModel = Self.isReasoningModel(model)
let isGPT5 = Self.isGPT5Model(model)
let hasLargeOutputWindow = isReasoningModel || isGPT5 || model == .chatLatest
let maxOutputTokens = model == .gpt5ChatLatest ? 16384 : (hasLargeOutputWindow ? 128_000 : 4096)
self.capabilities = ModelCapabilities(
supportsVision: model.supportsVision,
supportsTools: model.supportsTools,
supportsStreaming: true,
contextLength: model.contextLength,
maxOutputTokens: isReasoningModel || isGPT5 ? 128_000 : 4096,
maxOutputTokens: maxOutputTokens,
)
}
@ -65,7 +78,8 @@ public final class OpenAIResponsesProvider: ModelProvider {
let url = URL(string: "\(baseURL!)/responses")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.setValue("Bearer \(self.apiKey!)", forHTTPHeaderField: "Authorization")
let (authHeaderName, prefix, secret) = self.authHeader()
urlRequest.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName)
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
// Add OpenAI-specific headers
@ -134,7 +148,8 @@ public final class OpenAIResponsesProvider: ModelProvider {
let finalURLRequest: URLRequest = {
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Bearer \(self.apiKey!)", forHTTPHeaderField: "Authorization")
let (authHeaderName, prefix, secret) = self.authHeader()
req.setValue("\(prefix)\(secret)", forHTTPHeaderField: authHeaderName)
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
@ -198,6 +213,21 @@ public final class OpenAIResponsesProvider: ModelProvider {
// Parse the entire response for Linux
let responseText = String(data: data, encoding: .utf8) ?? ""
let lines = responseText.components(separatedBy: "\n")
var streamState = ResponsesStreamState()
for line in lines {
if
try Self.processResponsesStreamLine(
line,
model: self.model,
state: &streamState,
continuation: continuation,
)
{
return
}
}
continuation.finish()
return
#else
// macOS/iOS: Use streaming API
let (bytes, response) = try await self.session.bytes(for: finalURLRequest)
@ -227,157 +257,18 @@ public final class OpenAIResponsesProvider: ModelProvider {
throw TachikomaError.apiError("Failed to start streaming: \(errorMessage)")
}
var previousContent = "" // Track previously sent content for GPT-5 preambles
struct PartialToolCall {
var id: String
var name: String?
var arguments: String
}
var pendingToolCalls: [String: PartialToolCall] = [:]
var streamState = ResponsesStreamState()
for try await line in bytes.lines {
// Handle SSE format
if line.hasPrefix("data: ") {
let jsonString = String(line.dropFirst(6))
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA_STREAM"] != nil {
Self.debugLog("raw stream: \(jsonString)")
}
if jsonString == "[DONE]" {
continuation.finish()
return
}
if let data = jsonString.data(using: .utf8) {
// Try GPT-5 format first
if Self.isGPT5Model(self.model) {
if
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let eventType = event["type"] as? String
{
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA"] != nil {
Self.debugLog("event: \(eventType) payload: \(event)")
}
switch eventType {
case "response.output_text.delta":
if let delta = event["delta"] as? String, !delta.isEmpty {
continuation.yield(TextStreamDelta.text(delta))
}
case "response.output_item.added":
if
let item = event["item"] as? [String: Any],
let itemType = item["type"] as? String,
itemType == "function_call"
{
let identifier = (item["id"] as? String) ??
(item["call_id"] as? String) ?? UUID().uuidString
var partial = pendingToolCalls[identifier] ?? PartialToolCall(
id: identifier,
name: nil,
arguments: "",
)
if let name = item["name"] as? String {
partial.name = name
}
pendingToolCalls[identifier] = partial
}
case "response.function_call_arguments.delta":
if
let itemId = event["item_id"] as? String,
let delta = event["delta"] as? String
{
var partial = pendingToolCalls[itemId] ?? PartialToolCall(
id: itemId,
name: nil,
arguments: "",
)
partial.arguments.append(delta)
pendingToolCalls[itemId] = partial
}
case "response.function_call_arguments.done":
if
let itemId = event["item_id"] as? String,
let arguments = event["arguments"] as? String
{
var partial = pendingToolCalls[itemId] ?? PartialToolCall(
id: itemId,
name: nil,
arguments: "",
)
partial.arguments = arguments
pendingToolCalls[itemId] = partial
if
let name = partial.name,
let toolCall = Self.makeToolCall(
id: itemId,
name: name,
argumentsJSON: arguments,
)
{
continuation.yield(.tool(toolCall))
pendingToolCalls.removeValue(forKey: itemId)
}
}
case "response.completed":
continuation.finish()
return
default:
break
}
}
} else {
// Try standard Responses API format (O3, etc.)
do {
let chunk = try JSONDecoder().decode(
OpenAIResponsesStreamChunk.self,
from: data,
)
// Convert to TextStreamDelta
if
let choice = chunk.choices.first,
let content = choice.delta.content,
!content.isEmpty
{
// Handle accumulated content for models with preambles
if content.hasPrefix(previousContent), !previousContent.isEmpty {
// This is accumulated content, extract just the delta
let delta = String(content.dropFirst(previousContent.count))
if !delta.isEmpty {
continuation.yield(TextStreamDelta.text(delta))
previousContent = content // Update the accumulated content
}
} else {
// This is a true delta or the first chunk
continuation.yield(TextStreamDelta.text(content))
previousContent += content // Accumulate for comparison
}
}
// Check for finish
if
let choice = chunk.choices.first,
choice.finishReason != nil
{
continuation.finish()
return
}
} catch {
// Ignore parsing errors for incomplete chunks
}
}
}
} else if line.hasPrefix("event: ") {
// Track event types for GPT-5 streaming (but we handle them in data lines)
// This helps us understand the stream structure
if
try Self.processResponsesStreamLine(
line,
model: self.model,
state: &streamState,
continuation: continuation,
)
{
return
}
}
@ -390,6 +281,210 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
}
private struct ResponsesStreamState {
struct PartialToolCall {
var id: String
var name: String?
var arguments: String
}
var previousContent = ""
var pendingToolCalls: [String: PartialToolCall] = [:]
var didYieldToolCall = false
var didReceiveRefusal = false
}
private static func processResponsesStreamLine(
_ line: String,
model: LanguageModel.OpenAI,
state: inout ResponsesStreamState,
continuation: AsyncThrowingStream<TextStreamDelta, Error>.Continuation,
) throws
-> Bool
{
guard line.hasPrefix("data: ") else {
return false
}
let jsonString = String(line.dropFirst(6))
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA_STREAM"] != nil {
Self.debugLog("raw stream: \(jsonString)")
}
if jsonString == "[DONE]" {
continuation.finish()
return true
}
guard let data = jsonString.data(using: .utf8) else {
return false
}
if Self.usesResponsesEventStream(model) {
guard
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let eventType = event["type"] as? String else
{
return false
}
if ProcessInfo.processInfo.environment["DEBUG_TACHIKOMA"] != nil {
Self.debugLog("event: \(eventType) payload: \(event)")
}
switch eventType {
case "response.output_text.delta":
if let delta = event["delta"] as? String, !delta.isEmpty {
continuation.yield(TextStreamDelta.text(delta))
}
case "response.output_item.added":
if
let item = event["item"] as? [String: Any],
let itemType = item["type"] as? String,
itemType == "function_call"
{
let identifier = (item["id"] as? String) ??
(item["call_id"] as? String) ?? UUID().uuidString
var partial = state.pendingToolCalls[identifier] ?? ResponsesStreamState.PartialToolCall(
id: identifier,
name: nil,
arguments: "",
)
if let name = item["name"] as? String {
partial.name = name
}
state.pendingToolCalls[identifier] = partial
}
case "response.function_call_arguments.delta":
if
let itemId = event["item_id"] as? String,
let delta = event["delta"] as? String
{
var partial = state.pendingToolCalls[itemId] ?? ResponsesStreamState.PartialToolCall(
id: itemId,
name: nil,
arguments: "",
)
partial.arguments.append(delta)
state.pendingToolCalls[itemId] = partial
}
case "response.function_call_arguments.done":
if
let itemId = event["item_id"] as? String,
let arguments = event["arguments"] as? String
{
var partial = state.pendingToolCalls[itemId] ?? ResponsesStreamState.PartialToolCall(
id: itemId,
name: nil,
arguments: "",
)
partial.arguments = arguments
state.pendingToolCalls[itemId] = partial
if
let name = partial.name,
let toolCall = Self.makeToolCall(id: itemId, name: name, argumentsJSON: arguments)
{
continuation.yield(.tool(toolCall))
state.didYieldToolCall = true
state.pendingToolCalls.removeValue(forKey: itemId)
}
}
case "response.refusal.delta",
"response.refusal.done":
state.didReceiveRefusal = true
case "response.completed":
let finishReason: FinishReason = state.didReceiveRefusal
? .contentFilter
: (state.didYieldToolCall ? .toolCalls : .stop)
continuation.yield(.done(finishReason: finishReason))
continuation.finish()
return true
case "response.incomplete":
let finishReason = Self.finishReasonForIncompleteResponseEvent(event)
continuation.yield(.done(finishReason: finishReason))
continuation.finish()
return true
case "response.failed",
"error":
throw TachikomaError.apiError(Self.errorMessageForResponseStreamEvent(event))
default:
break
}
return false
}
do {
let chunk = try JSONDecoder().decode(OpenAIResponsesStreamChunk.self, from: data)
if
let choice = chunk.choices.first,
let content = choice.delta.content,
!content.isEmpty
{
if content.hasPrefix(state.previousContent), !state.previousContent.isEmpty {
let delta = String(content.dropFirst(state.previousContent.count))
if !delta.isEmpty {
continuation.yield(TextStreamDelta.text(delta))
state.previousContent = content
}
} else {
continuation.yield(TextStreamDelta.text(content))
state.previousContent += content
}
}
if let choice = chunk.choices.first, let finishReason = choice.finishReason {
continuation.yield(.done(finishReason: Self.finishReasonForChatStream(finishReason)))
continuation.finish()
return true
}
} catch {
// Ignore parsing errors for incomplete chunks.
}
return false
}
private static func finishReasonForChatStream(_ reason: String) -> FinishReason {
switch reason {
case "stop": .stop
case "length": .length
case "tool_calls": .toolCalls
case "content_filter": .contentFilter
default: .other
}
}
private static func errorMessageForResponseStreamEvent(_ event: [String: Any]) -> String {
let eventType = event["type"] as? String ?? "error"
let errorPayload = (event["error"] as? [String: Any]) ??
(event["response"] as? [String: Any]).flatMap { $0["error"] as? [String: Any] }
if let message = errorPayload?["message"] as? String, !message.isEmpty {
return "OpenAI Responses API stream \(eventType): \(message)"
}
if let message = event["message"] as? String, !message.isEmpty {
return "OpenAI Responses API stream \(eventType): \(message)"
}
return "OpenAI Responses API stream \(eventType)"
}
private func authHeader() -> (String, String, String) {
switch self.auth {
case let .apiKey(key):
("Authorization", "Bearer ", key)
case let .bearer(token, _):
("Authorization", "Bearer ", token)
}
}
// MARK: - Private Helpers
private func buildResponsesRequest(
@ -534,6 +629,29 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
}
private static func finishReasonForIncompleteResponseEvent(_ event: [String: Any]) -> FinishReason {
guard
let response = event["response"] as? [String: Any],
let incompleteDetails = response["incomplete_details"] as? [String: Any],
let reason = incompleteDetails["reason"] as? String else
{
return .other
}
return Self.finishReasonForIncompleteReason(reason)
}
private static func finishReasonForIncompleteReason(_ reason: String?) -> FinishReason {
switch reason {
case "content_filter":
.contentFilter
case "max_output_tokens":
.length
default:
.other
}
}
private func makeMessageEntry(role: String, message: ModelMessage) -> ResponsesMessage? {
let parts = self.convertContentParts(for: message)
guard !parts.isEmpty else { return nil }
@ -731,15 +849,16 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
static func convertToProviderResponse(_ response: OpenAIResponsesResponse) throws -> ProviderResponse {
// Handle GPT-5 format (output array) vs O3 format (choices array)
let text: String
let toolCalls: [AgentToolCall]?
let finishReason: FinishReason?
// Handle GPT-5 output arrays and alternate choices arrays.
var text: String
var toolCalls: [AgentToolCall]?
var finishReason: FinishReason?
if let outputs = response.output {
// GPT-5 format with output array
var collectedText = ""
var collectedToolCalls: [AgentToolCall] = []
var didCollectRefusal = false
for output in outputs {
if output.type == "message" {
@ -750,6 +869,10 @@ public final class OpenAIResponsesProvider: ModelProvider {
if let textSegment = chunk.text {
collectedText.append(textSegment)
}
case "refusal":
if chunk.refusal != nil || chunk.text != nil {
didCollectRefusal = true
}
case "tool_call":
if
let toolCall = chunk.toolCall,
@ -771,14 +894,24 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
text = collectedText
toolCalls = collectedToolCalls.isEmpty ? nil : collectedToolCalls
if let toolCalls, !toolCalls.isEmpty {
finishReason = .toolCalls
let incompleteFinishReason = response.status == "incomplete"
? Self.finishReasonForIncompleteReason(response.incompleteDetails?.reason)
: nil
if incompleteFinishReason == .contentFilter || didCollectRefusal {
text = ""
toolCalls = nil
finishReason = .contentFilter
} else {
finishReason = .stop
toolCalls = collectedToolCalls.isEmpty ? nil : collectedToolCalls
}
if finishReason == nil, let toolCalls, !toolCalls.isEmpty {
finishReason = .toolCalls
}
if finishReason == nil {
finishReason = incompleteFinishReason ?? .stop
}
} else if let choices = response.choices, let choice = choices.first {
// O3 format with choices array
// Alternate format with choices array.
text = choice.message.content ?? ""
// Convert tool calls
@ -790,20 +923,26 @@ public final class OpenAIResponsesProvider: ModelProvider {
case "stop": finishReason = .stop
case "length": finishReason = .length
case "tool_calls": finishReason = .toolCalls
case "content_filter": finishReason = .contentFilter
default: finishReason = .stop
}
} else {
finishReason = nil
}
if finishReason == .contentFilter || choice.message.refusal != nil {
text = ""
toolCalls = nil
finishReason = .contentFilter
}
} else {
throw TachikomaError.apiError("No output or choices in response")
}
// Convert usage (handle both GPT-5 and O3 formats)
// Convert usage across Responses API token field variants.
let usage: Usage?
if let apiUsage = response.usage {
// GPT-5 uses input_tokens/output_tokens
// O3 uses prompt_tokens/completion_tokens
// Alternate responses can use prompt_tokens/completion_tokens.
let inputTokens = apiUsage.inputTokens ?? apiUsage.promptTokens ?? 0
let outputTokens = apiUsage.outputTokens ?? apiUsage.completionTokens ?? 0
@ -898,31 +1037,28 @@ public final class OpenAIResponsesProvider: ModelProvider {
}
}
private static func isReasoningModel(_ model: LanguageModel.OpenAI) -> Bool {
private static func isReasoningModel(_: LanguageModel.OpenAI) -> Bool {
false
}
private static func isGPT5Model(_ model: LanguageModel.OpenAI) -> Bool {
switch model {
case .o4Mini:
case .gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano:
true
default:
false
}
}
private static func isGPT5Model(_ model: LanguageModel.OpenAI) -> Bool {
switch model {
case .gpt52,
.gpt51,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano,
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
true
default:
false
}
private static func usesResponsesEventStream(_ model: LanguageModel.OpenAI) -> Bool {
model == .chatLatest || model == .gpt5ChatLatest || self.isGPT5Model(model)
}
}

View File

@ -28,7 +28,7 @@ struct OpenAIResponsesRequest: Encodable {
let serviceTier: String?
let include: [String]?
/// Reasoning configuration (for o3/o4/GPT-5)
/// Reasoning configuration (for GPT-5 thinking models)
let reasoning: ReasoningConfig?
/// Truncation for long inputs
@ -477,18 +477,24 @@ struct OpenAIResponsesResponse: Codable {
let id: String
let object: String?
let createdAt: Int? // GPT-5 uses created_at
let created: Int? // O3 uses created
let created: Int? // Alternate responses can use created
let status: String?
let model: String
let output: [ResponsesOutput]? // GPT-5 uses output array
let choices: [ResponsesChoice]? // O3 uses choices array
let choices: [ResponsesChoice]? // Alternate responses can use choices array
let usage: ResponsesUsage?
let metadata: ResponsesMetadata?
let incompleteDetails: IncompleteDetails?
enum CodingKeys: String, CodingKey {
case id, object, status, model, output, choices, usage, metadata
case createdAt = "created_at"
case created
case incompleteDetails = "incomplete_details"
}
struct IncompleteDetails: Codable {
let reason: String?
}
/// GPT-5 output format
@ -513,17 +519,31 @@ struct OpenAIResponsesResponse: Codable {
struct OutputContent: Codable {
let type: String
let text: String?
let refusal: String?
let toolCall: ResponsesToolCall?
init(
type: String,
text: String? = nil,
refusal: String? = nil,
toolCall: ResponsesToolCall? = nil,
) {
self.type = type
self.text = text
self.refusal = refusal
self.toolCall = toolCall
}
enum CodingKeys: String, CodingKey {
case type
case text
case refusal
case toolCall = "tool_call"
}
}
}
/// O3 choices format (kept for compatibility)
/// Alternate choices format.
struct ResponsesChoice: Codable {
let index: Int
let message: ResponsesOutputMessage
@ -614,7 +634,7 @@ struct OpenAIResponsesResponse: Codable {
// MARK: - Streaming Response Types
/// Server-sent event for streaming responses (O3 and older models)
/// Server-sent event for alternate streaming responses.
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct OpenAIResponsesStreamChunk: Codable {
let id: String

View File

@ -77,11 +77,14 @@ struct OpenAIChatMessage: Codable {
let content: Either<String, [OpenAIChatMessageContent]>?
let toolCallId: String?
let toolCalls: [AgentToolCall]?
let reasoning: String?
let reasoningDetails: [JSONValue]?
enum CodingKeys: String, CodingKey {
case role, content
case role, content, reasoning
case toolCallId = "tool_call_id"
case toolCalls = "tool_calls"
case reasoningDetails = "reasoning_details"
}
struct AgentToolCall: Codable {
@ -100,6 +103,8 @@ struct OpenAIChatMessage: Codable {
self.content = .left(content)
self.toolCallId = toolCallId
self.toolCalls = nil
self.reasoning = nil
self.reasoningDetails = nil
}
init(role: String, content: [OpenAIChatMessageContent], toolCallId: String? = nil) {
@ -107,13 +112,23 @@ struct OpenAIChatMessage: Codable {
self.content = .right(content)
self.toolCallId = toolCallId
self.toolCalls = nil
self.reasoning = nil
self.reasoningDetails = nil
}
init(role: String, content: String? = nil, toolCalls: [AgentToolCall]?) {
init(
role: String,
content: String? = nil,
toolCalls: [AgentToolCall]?,
reasoning: String? = nil,
reasoningDetails: [JSONValue]? = nil,
) {
self.role = role
self.content = content.map { .left($0) }
self.toolCallId = nil
self.toolCalls = toolCalls
self.reasoning = reasoning
self.reasoningDetails = reasoningDetails
}
}
@ -246,10 +261,13 @@ struct OpenAIChatResponse: Codable {
let role: String
let content: String?
let toolCalls: [AgentToolCall]?
let reasoning: String?
let reasoningDetails: [JSONValue]?
enum CodingKeys: String, CodingKey {
case role, content
case role, content, reasoning
case toolCalls = "tool_calls"
case reasoningDetails = "reasoning_details"
}
}

View File

@ -24,21 +24,27 @@ public final class OpenRouterProvider: ModelProvider {
if let key = configuration.getAPIKey(for: .custom("openrouter")) {
self.apiKey = key
} else if let auth = TKAuthManager.shared.resolveAuth(for: .openrouter) {
switch auth {
case let .apiKey(key), let .bearer(key, _):
self.apiKey = key
}
} else {
throw TachikomaError.authenticationFailed("OPENROUTER_API_KEY not found")
}
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
self.capabilities = ModelCapabilities(
supportsVision: true,
supportsTools: true,
supportsStreaming: true,
contextLength: 128_000,
maxOutputTokens: 4096,
supportsStreaming: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
contextLength: isFable ? 1_000_000 : 128_000,
maxOutputTokens: isFable ? 128_000 : 4096,
)
self.defaultHeaders = [
"HTTP-Referer": ProcessInfo.processInfo.environment["OPENROUTER_REFERER"] ?? "https://peekaboo.app",
"X-Title": ProcessInfo.processInfo.environment["OPENROUTER_TITLE"] ?? "Peekaboo",
"X-OpenRouter-Title": ProcessInfo.processInfo.environment["OPENROUTER_TITLE"] ?? "Peekaboo",
]
}
@ -62,6 +68,9 @@ public final class OpenRouterProvider: ModelProvider {
guard let baseURL, let apiKey else {
throw TachikomaError.invalidConfiguration("OpenRouter provider missing base URL or API key")
}
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: self.modelId) else {
throw TachikomaError.invalidConfiguration("\(self.modelId) does not support streaming")
}
return try await OpenAICompatibleHelper.streamText(
request: request,

View File

@ -15,19 +15,18 @@ public struct ProviderFactory {
// Create a provider for the specified language model
switch model {
case let .openai(openaiModel):
// Use Responses API for reasoning models (o4) and GPT-5 family
// Use Responses API for the GPT-5 family
switch openaiModel {
case .o4Mini,
.gpt52,
.gpt51,
case .chatLatest,
.gpt5ChatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5,
.gpt5Pro,
.gpt5Mini,
.gpt5Nano,
.gpt5Thinking,
.gpt5ThinkingMini,
.gpt5ThinkingNano,
.gpt5ChatLatest:
.gpt5Nano:
return try OpenAIResponsesProvider(model: openaiModel, configuration: configuration)
default:
return try OpenAIProvider(model: openaiModel, configuration: configuration)
@ -59,6 +58,28 @@ public struct ProviderFactory {
modelId: lmstudioModel.modelId,
)
case let .minimax(minimaxModel):
guard let apiKey = configuration.getAPIKey(for: .minimax) else {
throw TachikomaError.authenticationFailed("MINIMAX_API_KEY not found")
}
return try Self.makeMiniMaxProvider(
model: minimaxModel,
provider: .minimax,
apiKey: apiKey,
configuration: configuration,
)
case let .minimaxCN(minimaxModel):
guard let apiKey = configuration.getAPIKey(for: .minimaxCN) ?? configuration.getAPIKey(for: .minimax) else {
throw TachikomaError.authenticationFailed("MINIMAX_CN_API_KEY or MINIMAX_API_KEY not found")
}
return try Self.makeMiniMaxProvider(
model: minimaxModel,
provider: .minimaxCN,
apiKey: apiKey,
configuration: configuration,
)
case let .openRouter(modelId):
return try OpenRouterProvider(modelId: modelId, configuration: configuration)
@ -94,12 +115,18 @@ public struct ProviderFactory {
modelId: parsed.model,
baseURL: custom.baseURL,
configuration: configuration,
apiKey: custom.apiKey,
additionalHeaders: custom.headers,
)
case .anthropic:
return try AnthropicCompatibleProvider(
modelId: parsed.model,
baseURL: custom.baseURL,
configuration: configuration,
apiKey: custom.apiKey,
additionalHeaders: custom.headers,
reasoningProvider: "custom-anthropic",
reasoningBaseURL: custom.baseURL,
)
}
}
@ -107,6 +134,34 @@ public struct ProviderFactory {
return provider
}
}
private static func makeMiniMaxProvider(
model: LanguageModel.MiniMax,
provider: Provider,
apiKey: String,
configuration: TachikomaConfiguration,
) throws
-> any ModelProvider
{
let baseURL = configuration.getBaseURL(for: provider) ?? provider.defaultBaseURL ?? "https://api.minimax.io/anthropic"
return try AnthropicCompatibleProvider(
modelId: model.modelId,
baseURL: baseURL,
configuration: configuration,
apiKey: apiKey,
// MiniMax's Anthropic-compatible setup uses Claude Code-style Authorization auth, not Anthropic x-api-key.
auth: .bearer(apiKey, betaHeader: nil),
capabilities: ModelCapabilities(
supportsVision: model.supportsVision,
supportsTools: model.supportsTools,
supportsStreaming: true,
contextLength: model.contextLength,
maxOutputTokens: 8192,
),
reasoningProvider: provider == .minimaxCN ? "minimax-cn" : "minimax",
reasoningBaseURL: baseURL,
)
}
}
// MARK: - Third-Party Aggregators

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-4", "claude-3", "llava:latest")
/// The model name (e.g., "gpt-5.5", "claude-fable-5", "llava:latest")
public let model: String
/// The full string representation (e.g., "openai/gpt-4")
/// The full string representation (e.g., "openai/gpt-5.5")
public var fullString: String {
"\(self.provider)/\(self.model)"
}
@ -23,7 +23,7 @@ public enum ProviderParser {
}
/// Parse a provider string in the format "provider/model"
/// - Parameter providerString: String like "openai/gpt-4" or "ollama/llava:latest"
/// - Parameter providerString: String like "openai/gpt-5.5" or "ollama/llava:latest"
/// - Returns: Parsed configuration or nil if invalid format
public static func parse(_ providerString: String) -> ProviderConfig? {
// Parse a provider string in the format "provider/model"
@ -44,7 +44,7 @@ public enum ProviderParser {
}
/// Parse a comma-separated list of providers
/// - Parameter providersString: String like "openai/gpt-4,anthropic/claude-3,ollama/llava:latest"
/// - Parameter providersString: String like "openai/gpt-5.5,anthropic/claude-fable-5,ollama/llava:latest"
/// - Returns: Array of parsed configurations
public static func parseList(_ providersString: String) -> [ProviderConfig] {
// Parse a comma-separated list of providers
@ -54,7 +54,7 @@ public enum ProviderParser {
}
/// Get the first provider from a comma-separated list
/// - Parameter providersString: String like "openai/gpt-4,anthropic/claude-3"
/// - Parameter providersString: String like "openai/gpt-5.5,anthropic/claude-fable-5"
/// - Returns: First parsed configuration or nil if none valid
public static func parseFirst(_ providersString: String) -> ProviderConfig? {
// Get the first provider from a comma-separated list
@ -94,6 +94,8 @@ public enum ProviderParser {
/// - hasOpenAI: Whether OpenAI API key is available
/// - hasAnthropic: Whether Anthropic API key is available
/// - hasGrok: Whether Grok API key is available
/// - hasGoogle: Whether Google/Gemini API key is available
/// - hasMiniMax: Whether MiniMax API key is available
/// - hasOllama: Whether Ollama is available (always true as it doesn't require API key)
/// - configuredDefault: Optional default from configuration
/// - isEnvironmentProvided: Whether the providers string came from environment variable
@ -103,6 +105,8 @@ public enum ProviderParser {
hasOpenAI: Bool,
hasAnthropic: Bool,
hasGrok: Bool = false,
hasGoogle: Bool? = nil,
hasMiniMax: Bool = false,
hasOllama: Bool = true,
configuredDefault: LanguageModel? = nil,
isEnvironmentProvided: Bool = false,
@ -113,14 +117,21 @@ public enum ProviderParser {
let providers = self.parseList(providersString)
var environmentModel: LanguageModel?
let canUseGoogleProvider = hasGoogle ?? true
let canFallbackToGoogle = hasGoogle ?? false
for config in providers {
switch config.provider.lowercased() {
case "openai" where hasOpenAI:
environmentModel = self.parseOpenAIModel(config.model)
case "anthropic" where hasAnthropic:
environmentModel = self.parseAnthropicModel(config.model)
case "google", "gemini":
case "google" where canUseGoogleProvider, "gemini" where canUseGoogleProvider:
environmentModel = self.parseGoogleModel(config.model)
case "minimax" where hasMiniMax:
environmentModel = self.parseMiniMaxModel(config.model)
case "minimax-cn" where hasMiniMax, "minimax_cn" where hasMiniMax, "minimaxi" where hasMiniMax:
environmentModel = self.parseMiniMaxCNModel(config.model)
case "grok" where hasGrok, "xai" where hasGrok:
environmentModel = self.parseGrokModel(config.model)
case "ollama" where hasOllama:
@ -151,6 +162,8 @@ public enum ProviderParser {
hasOpenAI: hasOpenAI,
hasAnthropic: hasAnthropic,
hasGrok: hasGrok,
hasGoogle: canFallbackToGoogle,
hasMiniMax: hasMiniMax,
hasOllama: hasOllama,
)
}
@ -169,6 +182,8 @@ public enum ProviderParser {
hasOpenAI: Bool,
hasAnthropic: Bool,
hasGrok: Bool = false,
hasGoogle: Bool? = nil,
hasMiniMax: Bool = false,
hasOllama: Bool = true,
configuredDefault: LanguageModel? = nil,
)
@ -180,6 +195,8 @@ public enum ProviderParser {
hasOpenAI: hasOpenAI,
hasAnthropic: hasAnthropic,
hasGrok: hasGrok,
hasGoogle: hasGoogle,
hasMiniMax: hasMiniMax,
hasOllama: hasOllama,
configuredDefault: configuredDefault,
isEnvironmentProvided: false,
@ -202,32 +219,38 @@ public enum ProviderParser {
// MARK: - Private Helpers
private static func parseOpenAIModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "o4-mini": .openai(.o4Mini)
case "gpt-5.2", "gpt5.2", "gpt-5-2", "gpt5-2", "gpt52": .openai(.gpt52)
case "gpt-5.2-mini", "gpt5.2-mini", "gpt-5-2-mini", "gpt5-2-mini", "gpt52-mini", "gpt52mini":
let normalized = modelString.lowercased()
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
guard
!normalized.hasPrefix("gpt-4"), !compact.hasPrefix("gpt4"),
!normalized.hasPrefix("gpt-3"), !compact.hasPrefix("gpt3"),
!normalized.hasPrefix("o3"), !normalized.hasPrefix("o4"),
!normalized.hasPrefix("gpt-5.1"), !compact.hasPrefix("gpt51"),
!normalized.hasPrefix("gpt-5.2"), !compact.hasPrefix("gpt52"),
!normalized.contains("gpt-5-thinking"), !compact.contains("gpt5thinking") else
{
return nil
}
return switch normalized {
case "chat-latest", "chatlatest":
.openai(.chatLatest)
case "gpt-5-chat-latest", "gpt5-chat-latest", "gpt5chatlatest":
.openai(.gpt5ChatLatest)
case "gpt-5.5", "gpt5.5", "gpt-5-5", "gpt5-5", "gpt55": .openai(.gpt55)
case "gpt-5.5-mini", "gpt5.5-mini", "gpt-5-5-mini", "gpt5-5-mini", "gpt55-mini", "gpt55mini":
.openai(.gpt5Mini)
case "gpt-5.2-nano", "gpt5.2-nano", "gpt-5-2-nano", "gpt5-2-nano", "gpt52-nano", "gpt52nano":
.openai(.gpt5Nano)
case "gpt-5.1", "gpt5.1", "gpt-5-1", "gpt5-1", "gpt51": .openai(.gpt51)
case "gpt-5.1-mini", "gpt5.1-mini", "gpt-5-1-mini", "gpt5-1-mini", "gpt51-mini", "gpt51mini":
.openai(.gpt5Mini)
case "gpt-5.1-nano", "gpt5.1-nano", "gpt-5-1-nano", "gpt5-1-nano", "gpt51-nano", "gpt51nano":
case "gpt-5.5-nano", "gpt5.5-nano", "gpt-5-5-nano", "gpt5-5-nano", "gpt55-nano", "gpt55nano":
.openai(.gpt5Nano)
case "gpt-5.4", "gpt5.4", "gpt-5-4", "gpt5-4", "gpt54": .openai(.gpt54)
case "gpt-5.4-mini", "gpt5.4-mini", "gpt-5-4-mini", "gpt5-4-mini", "gpt54-mini", "gpt54mini":
.openai(.gpt54Mini)
case "gpt-5.4-nano", "gpt5.4-nano", "gpt-5-4-nano", "gpt5-4-nano", "gpt54-nano", "gpt54nano":
.openai(.gpt54Nano)
case "gpt-5", "gpt5": .openai(.gpt5)
case "gpt-5-pro", "gpt5-pro", "gpt5pro": .openai(.gpt5Pro)
case "gpt-5-mini", "gpt5-mini", "gpt5mini": .openai(.gpt5Mini)
case "gpt-5-nano", "gpt5-nano", "gpt5nano": .openai(.gpt5Nano)
case "gpt-5-thinking", "gpt5-thinking", "gpt5thinking": .openai(.gpt5Thinking)
case "gpt-5-thinking-mini", "gpt5-thinking-mini", "gpt5thinkingmini": .openai(.gpt5ThinkingMini)
case "gpt-5-thinking-nano", "gpt5-thinking-nano", "gpt5thinkingnano": .openai(.gpt5ThinkingNano)
case "gpt-5-chat-latest", "gpt5-chat-latest": .openai(.gpt5ChatLatest)
case "gpt-4.1", "gpt4.1": .openai(.gpt41)
case "gpt-4.1-mini", "gpt4.1-mini": .openai(.gpt41Mini)
case "gpt-4o", "gpt4o": .openai(.gpt4o)
case "gpt-4o-mini", "gpt4o-mini": .openai(.gpt4oMini)
case "gpt-4-turbo", "gpt4-turbo": .openai(.gpt4Turbo)
case "gpt-3.5-turbo", "gpt35-turbo": .openai(.gpt35Turbo)
default:
// Handle custom/fine-tuned models
.openai(.custom(modelString))
@ -235,15 +258,34 @@ public enum ProviderParser {
}
private static func parseAnthropicModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
let normalized = modelString.lowercased()
let compact = normalized.replacingOccurrences(of: "-", with: "").replacingOccurrences(of: ".", with: "")
guard
!normalized.hasPrefix("claude-3"), !compact.hasPrefix("claude3"),
normalized != "claude-opus-4-20250514",
normalized != "claude-sonnet-4-20250514",
!normalized.contains("-thinking") else
{
return nil
}
return switch normalized {
case "claude-fable-5", "claude-fable-5-latest", "fable-5", "fable.5", "fable5", "fable":
.anthropic(.fable5)
case "claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-8-latest", "opus-4-8", "opus-4.8",
"opus48", "claude", "claude-latest", "claude_latest", "claudelatest", "claude-default", "claude_default":
.anthropic(.opus48)
case "claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-7-latest", "opus-4-7", "opus-4.7", "opus47":
.anthropic(.opus47)
case "claude-opus-4-5", "claude-opus-4.5", "claude-opus-4-5-latest", "opus-4-5", "opus-4.5", "opus45":
.anthropic(.opus45)
case "claude-opus-4-1-20250805", "claude-opus-4-20250514", "claude-opus-4", "opus-4": .anthropic(.opus4)
case "claude-opus-4-1-20250805-thinking", "claude-opus-4-20250514-thinking", "claude-opus-4-thinking",
"opus-4-thinking": .anthropic(.opus4Thinking)
case "claude-sonnet-4-20250514", "claude-sonnet-4", "sonnet-4": .anthropic(.sonnet4)
case "claude-sonnet-4-20250514-thinking", "claude-sonnet-4-thinking",
"sonnet-4-thinking": .anthropic(.sonnet4Thinking)
case "claude-opus-4-1-20250805", "claude-opus-4", "opus-4": .anthropic(.opus4)
case "claude-sonnet-4-6", "claude-sonnet-4.6", "sonnet-4-6", "sonnet-4.6", "sonnet46":
.anthropic(.sonnet46)
case "claude-sonnet-4-5-20250929", "claude-sonnet-4.5", "sonnet-4-5", "sonnet-4.5", "sonnet45":
.anthropic(.sonnet45)
case "claude-haiku-4-5-20251001", "claude-haiku-4-5", "claude-haiku-4.5", "haiku-4-5", "haiku45":
.anthropic(.haiku45)
default:
// Handle custom models
.anthropic(.custom(modelString))
@ -252,6 +294,12 @@ public enum ProviderParser {
private static func parseGoogleModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "gemini-3.5-flash", "gemini35flash", "gemini-3-5-flash":
.google(.gemini35Flash)
case "gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini31pro", "gemini31propreview":
.google(.gemini31ProPreview)
case "gemini-3.1-flash-lite", "gemini31flashlite", "gemini-3.1-flashlite":
.google(.gemini31FlashLite)
case "gemini-3-flash", "gemini-3-flash-preview", "gemini3flash", "gemini-3flash":
.google(.gemini3Flash)
case "gemini-2.5-pro", "gemini25pro", "gemini2.5pro":
@ -261,7 +309,29 @@ public enum ProviderParser {
case "gemini-2.5-flash-lite", "gemini25flashlite", "gemini-2.5-flashlite":
.google(.gemini25FlashLite)
case "gemini":
.google(.gemini3Flash)
.google(.gemini35Flash)
default:
nil
}
}
private static func parseMiniMaxModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7":
.minimax(.m27)
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed":
.minimax(.m27Highspeed)
default:
nil
}
}
private static func parseMiniMaxCNModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "minimax-m2.7", "minimax-m2-7", "m2.7", "m2-7":
.minimaxCN(.m27)
case "minimax-m2.7-highspeed", "minimax-m2-7-highspeed", "m2.7-highspeed", "m2-7-highspeed":
.minimaxCN(.m27Highspeed)
default:
nil
}
@ -269,22 +339,27 @@ public enum ProviderParser {
private static func parseGrokModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
case "grok-4-0709": .grok(.grok4)
case "grok-4-fast-reasoning": .grok(.grok4FastReasoning)
case "grok-4-fast-non-reasoning": .grok(.grok4FastNonReasoning)
case "grok-code-fast-1": .grok(.grokCodeFast1)
case "grok-3", "grok3": .grok(.grok3)
case "grok-3-mini": .grok(.grok3Mini)
case "grok-2-1212": .grok(.grok2)
case "grok-2-vision-1212": .grok(.grok2Vision)
case "grok-2-image-1212": .grok(.grok2Image)
case "grok-vision-beta": .grok(.grokVisionBeta)
case "grok-beta": .grok(.grokBeta)
case "grok-4.3", "grok-4-3", "grok43", "grok-4.3-latest", "grok-4-latest", "grok-4", "grok-latest":
return .grok(.grok43)
case "grok-4.20-0309-reasoning", "grok-4-20-0309-reasoning":
return .grok(.grok420Reasoning)
case "grok-4.20-0309-non-reasoning", "grok-4-20-0309-non-reasoning":
return .grok(.grok420NonReasoning)
default:
.grok(.custom(modelString))
if self.isUnsupportedLegacyGrokModel(modelString) {
return nil
}
return .grok(.custom(modelString))
}
}
private static func isUnsupportedLegacyGrokModel(_ modelString: String) -> Bool {
let normalized = modelString.lowercased()
return normalized.contains("grok-4.20-multi-agent") ||
normalized.contains("grok-4-20-multi-agent") ||
normalized.contains("grok420multiagent")
}
private static func parseOllamaModel(_ modelString: String) -> LanguageModel? {
switch modelString.lowercased() {
// GPT-OSS models
@ -300,7 +375,6 @@ public enum ProviderParser {
case "llava:34b": .ollama(.custom("llava:34b"))
case "mistral-nemo", "mistral-nemo:latest": .ollama(.mistralNemo)
case "qwen2.5", "qwen2.5:latest": .ollama(.qwen25)
case "codellama", "codellama:latest": .ollama(.codellama)
default:
.ollama(.custom(modelString))
}
@ -310,16 +384,22 @@ public enum ProviderParser {
hasOpenAI: Bool,
hasAnthropic: Bool,
hasGrok: Bool,
hasGoogle: Bool,
hasMiniMax: Bool,
hasOllama _: Bool,
)
-> LanguageModel
{
if hasAnthropic {
.anthropic(.opus4)
.anthropic(.opus48)
} else if hasOpenAI {
.openai(.gpt5Mini)
.openai(.gpt55)
} else if hasGrok {
.grok(.grok4FastReasoning)
.grok(.grok43)
} else if hasGoogle {
.google(.gemini35Flash)
} else if hasMiniMax {
.minimax(.m27)
} else {
.ollama(.llama33)
}

View File

@ -27,12 +27,13 @@ public final class TogetherProvider: ModelProvider {
throw TachikomaError.authenticationFailed("TOGETHER_API_KEY not found")
}
let isFable = LanguageModel.Anthropic.isFable(modelId: modelId)
self.capabilities = ModelCapabilities(
supportsVision: true,
supportsTools: true,
supportsStreaming: true,
contextLength: 128_000,
maxOutputTokens: 4096,
supportsStreaming: !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId),
contextLength: isFable ? 1_000_000 : 128_000,
maxOutputTokens: isFable ? 128_000 : 4096,
)
}
@ -55,6 +56,9 @@ public final class TogetherProvider: ModelProvider {
guard let baseURL, let apiKey else {
throw TachikomaError.invalidConfiguration("Together provider missing base URL or API key")
}
guard !LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: self.modelId) else {
throw TachikomaError.invalidConfiguration("\(self.modelId) does not support streaming")
}
return try await OpenAICompatibleHelper.streamText(
request: request,

View File

@ -6,18 +6,44 @@ import UIKit
// MARK: - Cache Key
/// Hashable key for cache entries
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct CacheProviderIdentity: Hashable, Sendable {
public let providerKind: String
public let modelId: String
public let endpointIdentity: String?
public init(providerKind: String, modelId: String, baseURL: String?) {
self.providerKind = providerKind
self.modelId = modelId
self.endpointIdentity = ReasoningEndpointIdentity.canonical(baseURL)
}
}
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct CacheKey: Hashable {
let hash: String
let model: String? // Store model ID for invalidation
let isCacheable: Bool
init(from request: ProviderRequest, model: String? = nil) {
self.model = model
init(from request: ProviderRequest, providerIdentity: CacheProviderIdentity? = nil, model: String? = nil) {
self.model = providerIdentity?.modelId ?? model
// Create a unique hash from the request
var hasher = Hasher()
if let providerIdentity {
hasher.combine(providerIdentity.providerKind)
hasher.combine(providerIdentity.modelId)
hasher.combine(providerIdentity.endpointIdentity)
}
// Combine message content
for message in request.messages {
hasher.combine(message.role.rawValue)
hasher.combine(message.channel?.rawValue)
hasher.combine(message.metadata?.conversationId)
hasher.combine(message.metadata?.turnId)
for key in message.metadata?.customData?.keys.sorted() ?? [] {
hasher.combine(key)
hasher.combine(message.metadata?.customData?[key])
}
for part in message.content {
switch part {
case let .text(text):
@ -41,8 +67,32 @@ struct CacheKey: Hashable {
hasher.combine(request.settings.temperature)
hasher.combine(request.settings.maxTokens)
hasher.combine(request.settings.topP)
hasher.combine(request.settings.topK)
hasher.combine(request.settings.frequencyPenalty)
hasher.combine(request.settings.presencePenalty)
hasher.combine(request.settings.stopSequences)
hasher.combine(request.settings.reasoningEffort?.rawValue)
hasher.combine(request.settings.seed)
if let stopConditions = request.settings.stopConditions {
guard let cacheKey = (stopConditions as? StableCacheKeyStopCondition)?.stableCacheKey else {
self.hash = ""
self.isCacheable = false
return
}
hasher.combine(cacheKey)
}
if let providerOptionsData = try? Self.providerOptionsEncoder.encode(request.settings.providerOptions) {
hasher.combine(providerOptionsData)
}
self.hash = String(hasher.finalize())
self.isCacheable = true
}
private static let providerOptionsEncoder: JSONEncoder = {
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
return encoder
}()
}
// MARK: - Response Cache
@ -75,11 +125,16 @@ public actor ResponseCache {
public func get(
for request: ProviderRequest,
ttlOverride: TimeInterval? = nil,
providerIdentity: CacheProviderIdentity? = nil,
)
-> ProviderResponse?
{
// Get cached response with TTL validation
let key = CacheKey(from: request)
let key = CacheKey(from: request, providerIdentity: providerIdentity)
guard key.isCacheable else {
self.statistics.recordMiss()
return nil
}
guard let entry = cache[key] else {
self.statistics.recordMiss()
@ -109,9 +164,13 @@ public actor ResponseCache {
for request: ProviderRequest,
ttl: TimeInterval? = nil,
priority: CachePriority = .normal,
providerIdentity: CacheProviderIdentity? = nil,
) {
// Store response with custom TTL and priority
let key = CacheKey(from: request)
let key = CacheKey(from: request, providerIdentity: providerIdentity)
guard key.isCacheable else {
return
}
// Check memory limit
if self.shouldEvictForMemory() {
@ -457,9 +516,33 @@ final class CacheEntry: @unchecked Sendable {
// Rough estimation based on response content
let textSize = self.response.text.utf8.count
let toolCallsSize = (response.toolCalls?.count ?? 0) * 100 // Estimate 100 bytes per tool call
let reasoningSize = self.response.reasoning.reduce(0) { total, block in
total + block.text.utf8.count +
(block.signature?.utf8.count ?? 0) +
(block.rawJSON?.utf8.count ?? 0) +
block.type.utf8.count
}
let assistantMessageSize = self.response.assistantMessages.reduce(0) { total, message in
let contentSize = message.content.reduce(0) { contentTotal, part in
switch part {
case let .text(text):
contentTotal + text.utf8.count
case let .image(image):
contentTotal + image.mimeType.utf8.count + image.data.utf8.count
case let .toolCall(call):
contentTotal + call.id.utf8.count + call.name.utf8.count + 100
case let .toolResult(result):
contentTotal + result.toolCallId.utf8.count + 100
}
}
let metadataSize = (message.metadata?.customData ?? [:]).reduce(0) { metadataTotal, pair in
metadataTotal + pair.key.utf8.count + pair.value.utf8.count
}
return total + contentSize + metadataSize + (message.channel?.rawValue.utf8.count ?? 0)
}
let usageSize = 50 // Fixed overhead for usage data
return textSize + toolCallsSize + usageSize + 100 // 100 bytes overhead
return textSize + toolCallsSize + reasoningSize + assistantMessageSize + usageSize + 100
}
}
@ -546,6 +629,7 @@ extension ResponseCache {
public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
let provider: Base
let cache: ResponseCache
private let providerIdentity: CacheProviderIdentity
public var modelId: String {
self.provider.modelId
@ -563,11 +647,21 @@ public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
self.provider.capabilities
}
init(provider: Base, cache: ResponseCache) {
self.provider = provider
self.cache = cache
self.providerIdentity = CacheProviderIdentity(
providerKind: String(reflecting: Base.self),
modelId: provider.modelId,
baseURL: provider.baseURL,
)
}
public func generateText(request: ProviderRequest) async throws -> ProviderResponse {
// Check cache with smart TTL based on request type
let ttl = self.determineTTL(for: request)
if let cached = await cache.get(for: request, ttlOverride: ttl) {
if let cached = await cache.get(for: request, ttlOverride: ttl, providerIdentity: self.providerIdentity) {
return cached
}
@ -575,7 +669,13 @@ public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
let response = try await provider.generateText(request: request)
let priority = self.determinePriority(for: request)
await self.cache.store(response, for: request, ttl: ttl, priority: priority)
await self.cache.store(
response,
for: request,
ttl: ttl,
priority: priority,
providerIdentity: self.providerIdentity,
)
return response
}

View File

@ -528,39 +528,38 @@ public struct ModelCostCalculator: Sendable {
// OpenAI Pricing (as of 2025)
case let .openai(openaiModel):
switch openaiModel {
case .o4Mini: (1.50, 6.00)
case .gpt52: (5.00, 20.00) // GPT-5.2 pricing estimate
case .gpt51: (5.00, 20.00) // GPT-5.1 pricing estimate
case .chatLatest: (5.00, 30.00) // ChatGPT Instant alias pricing estimate
case .gpt5ChatLatest: (1.25, 10.00)
case .gpt55: (5.00, 20.00) // GPT-5.5 pricing estimate
case .gpt54: (5.00, 20.00) // GPT-5.4 pricing estimate
case .gpt54Mini: (1.00, 4.00)
case .gpt54Nano: (0.50, 2.00)
case .gpt5: (5.00, 20.00) // GPT-5 pricing estimate
case .gpt5Pro: (12.00, 48.00) // Higher reasoning budget
case .gpt5Mini: (1.00, 4.00) // GPT-5 Mini pricing estimate
case .gpt5Nano: (0.50, 2.00) // GPT-5 Nano pricing estimate
case .gpt5Thinking: (16.00, 64.00) // Extended reasoning premium
case .gpt5ThinkingMini: (4.00, 16.00)
case .gpt5ThinkingNano: (1.50, 6.00)
case .gpt5ChatLatest: (2.50, 10.00)
case .gpt41: (2.50, 10.00)
case .gpt41Mini: (0.15, 0.60)
case .gpt4o: (2.50, 10.00)
case .gpt4oMini: (0.15, 0.60)
case .gpt4oRealtime: (5.00, 20.00) // Realtime API pricing estimate
case .gpt4Turbo: (10.00, 30.00)
case .gpt35Turbo: (0.50, 1.50)
case .custom: (2.50, 10.00) // Default estimate
}
// Anthropic Pricing (as of 2025)
// Anthropic Pricing (as of 2026)
case let .anthropic(anthropicModel):
switch anthropicModel {
case .fable5: (10.00, 50.00)
case .opus48: (5.00, 25.00)
case .opus47: (5.00, 25.00)
case .opus45: (5.00, 25.00)
case .opus4, .opus4Thinking: (15.00, 75.00)
case .sonnet4, .sonnet4Thinking: (3.00, 15.00)
case .opus4: (15.00, 75.00)
case .sonnet46: (3.00, 15.00)
case .sonnet45: (4.00, 18.00)
case .haiku45: (1.20, 6.00)
case .custom: (3.00, 15.00) // Default estimate
case let .custom(id):
id.lowercased().contains("claude-fable-5") ? (10.00, 50.00) : (3.00, 15.00)
}
// Google Pricing (estimates)
// Google Pricing (standard tier, as of 2026)
case let .google(googleModel):
switch googleModel {
case .gemini35Flash: (1.50, 9.00)
case .gemini31ProPreview: (1.25, 10.00)
case .gemini31FlashLite: (0.10, 0.40)
case .gemini3Flash: (0.50, 3.00)
case .gemini25Pro: (1.25, 10.00)
case .gemini25Flash: (0.30, 2.50)
@ -569,7 +568,12 @@ public struct ModelCostCalculator: Sendable {
// Other providers - estimates
case .mistral: (2.00, 6.00)
case .groq: (0.27, 0.27) // Groq has very low pricing
case .grok: (2.00, 8.00)
case let .grok(grokModel):
switch grokModel {
case .grok43, .grok420MultiAgent, .grok420Reasoning, .grok420NonReasoning: (1.25, 2.50)
case .custom: (2.00, 8.00)
}
case .minimax, .minimaxCN: (0.30, 1.20)
case .ollama: (0.00, 0.00) // Local inference
case .lmstudio: (0.00, 0.00) // Local inference
case .openRouter, .together, .replicate: (1.00, 3.00) // Typical aggregator pricing

View File

@ -26,32 +26,43 @@ public final class Agent<Context>: @unchecked Sendable {
public private(set) var tools: [AgentTool]
/// Language model used by this agent
public var model: LanguageModel
public var model: LanguageModel {
didSet {
self.usesImplicitDefaultModel = false
}
}
/// Generation settings for the agent
public var settings: GenerationSettings
/// Provider configuration for generation and streaming
private let configuration: TachikomaConfiguration
/// The context instance passed to tool executions
private let context: Context
/// Current conversation history
public private(set) var conversation: Conversation
private var usesImplicitDefaultModel: Bool
public init(
name: String,
instructions: String,
model: LanguageModel = .default,
model: LanguageModel? = nil,
tools: [AgentTool] = [],
settings: GenerationSettings = .default,
configuration: TachikomaConfiguration = .current,
context: Context,
) {
self.name = name
self.instructions = instructions
self.model = model
self.usesImplicitDefaultModel = model == nil
self.model = model ?? .default
self.tools = tools
self.settings = settings
self.configuration = configuration
self.context = context
self.conversation = Conversation()
self.conversation = Conversation(configuration: configuration)
// Add system message with instructions
self.conversation.addSystemMessage(instructions)
@ -71,66 +82,133 @@ public final class Agent<Context>: @unchecked Sendable {
/// Execute a single message with the agent
public func execute(_ message: String) async throws -> AgentResponse {
// Add user message to conversation
self.conversation.addUserMessage(message)
let conversation = self.conversation
let model = self.model
let tools = self.tools
let settings = self.settings
// Generate response using the conversation
let result = try await generateText(
model: model,
messages: conversation.getModelMessages(),
tools: self.tools.isEmpty ? nil : self.tools,
settings: self.settings,
maxSteps: 5, // Allow multi-step tool execution
)
return try await conversation.withContinuationLock {
conversation.addUserMessage(message)
let conversationMessages = conversation.messages
let modelMessages = conversationMessages.map { $0.toModelMessage() }
let snapshotIDs = conversationMessages.map(\.id)
let anchorID = conversationMessages.last?.id
let result = try await generateText(
model: model,
messages: modelMessages,
tools: tools.isEmpty ? nil : tools,
settings: settings,
maxSteps: 5, // Allow multi-step tool execution
configuration: self.configuration,
)
// Add assistant response to conversation
self.conversation.addAssistantMessage(result.text)
// Add any tool calls and results to conversation
for step in result.steps {
if !step.toolCalls.isEmpty {
for _ in step.toolCalls {
// Tool calls are already added by generateText
let didMerge: Bool
if result.finishReason == .contentFilter {
didMerge = conversation.mergeContentFilterResult(
result.messages,
originalMessages: modelMessages,
afterMessageID: anchorID,
validatingSnapshotIDs: snapshotIDs,
)
} else if let anchorID {
let generatedMessages = Array(result.messages.dropFirst(modelMessages.count))
let didMerge = conversation.appendGeneratedMessages(
generatedMessages,
afterMessageID: anchorID,
validatingSnapshotIDs: snapshotIDs,
)
guard didMerge else {
throw TachikomaError.invalidConfiguration(
"Conversation changed during generation; refusing to merge response",
)
}
return AgentResponse(
text: result.text,
usage: result.usage,
finishReason: result.finishReason ?? .other,
steps: result.steps,
conversationLength: conversation.messages.count,
)
} else {
didMerge = conversation.messages.isEmpty
}
if !step.toolResults.isEmpty {
for _ in step.toolResults {
// Tool results are already added by generateText
}
guard didMerge else {
throw TachikomaError.invalidConfiguration(
"Conversation changed during generation; refusing to merge response",
)
}
return AgentResponse(
text: result.text,
usage: result.usage,
finishReason: result.finishReason ?? .other,
steps: result.steps,
conversationLength: conversation.messages.count,
)
}
return AgentResponse(
text: result.text,
usage: result.usage,
finishReason: result.finishReason ?? .other,
steps: result.steps,
conversationLength: self.conversation.messages.count,
)
}
/// Stream a response from the agent
public func stream(_ message: String) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
let streamingModel = if self.usesImplicitDefaultModel {
LanguageModel.defaultStreaming
} else if self.model.supportsStreaming {
self.model
} else {
self.model
}
guard streamingModel.supportsStreaming else {
throw TachikomaError.invalidConfiguration("\(self.model.modelId) does not support streaming")
}
let conversation = self.conversation
try await conversation.acquireContinuationLock()
let gateRelease = AsyncReleaseOnce {
await conversation.releaseContinuationLock()
}
// Add user message to conversation
self.conversation.addUserMessage(message)
conversation.addUserMessage(message)
let conversationMessages = conversation.messages
let modelMessages = conversationMessages.map { $0.toModelMessage() }
let snapshotIDs = conversationMessages.map(\.id)
let buffersUntilDone = self.settings.streamBuffering == .untilTerminal
// Stream response
let streamResult = try await streamText(
model: model,
messages: conversation.getModelMessages(),
tools: self.tools.isEmpty ? nil : self.tools,
settings: self.settings,
maxSteps: 5,
)
let streamResult: StreamTextResult
do {
streamResult = try await streamText(
model: streamingModel,
messages: modelMessages,
tools: self.tools.isEmpty ? nil : self.tools,
settings: self.settings,
maxSteps: 5,
configuration: self.configuration,
)
} catch {
gateRelease.release()
throw error
}
// Track final message in conversation (this is approximate for streaming)
return AsyncThrowingStream<TextStreamDelta, Error> { continuation in
Task {
let producer = Task {
defer {
gateRelease.release()
}
do {
var assistantText = ""
var bufferedDeltas: [TextStreamDelta] = []
var didReceiveTerminal = false
for try await delta in streamResult.stream {
continuation.yield(delta)
try Task.checkCancellation()
if buffersUntilDone {
bufferedDeltas.append(delta)
} else {
continuation.yield(delta)
}
// Collect assistant text
if case .textDelta = delta.type, let content = delta.content {
@ -138,25 +216,60 @@ public final class Agent<Context>: @unchecked Sendable {
}
if case .done = delta.type {
didReceiveTerminal = true
guard delta.finishReason != .contentFilter else {
let didRollback = conversation.replaceModelMessages(
modelMessages.droppingLastUserTurn(),
validatingSnapshotIDs: snapshotIDs,
)
guard didRollback else {
throw TachikomaError.invalidConfiguration(
"Conversation changed during streaming; refusing to merge response",
)
}
assistantText = ""
bufferedDeltas.removeAll()
if buffersUntilDone {
continuation.yield(delta)
}
continue
}
if buffersUntilDone {
for bufferedDelta in bufferedDeltas {
continuation.yield(bufferedDelta)
}
bufferedDeltas.removeAll()
}
// Add final assistant message to conversation
if !assistantText.isEmpty {
self.conversation.addAssistantMessage(assistantText)
conversation.addAssistantMessage(assistantText)
assistantText = ""
}
}
}
if buffersUntilDone, !didReceiveTerminal, !bufferedDeltas.isEmpty {
throw TachikomaError.apiError("Stream ended before provider completion status was received")
}
if !buffersUntilDone, !assistantText.isEmpty {
try Task.checkCancellation()
conversation.addAssistantMessage(assistantText)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
producer.cancel()
}
}
}
/// Reset the agent's conversation history
public func resetConversation() {
// Reset the agent's conversation history
self.conversation = Conversation()
self.conversation = Conversation(configuration: self.configuration)
self.conversation.addSystemMessage(self.instructions)
}
@ -169,7 +282,7 @@ public final class Agent<Context>: @unchecked Sendable {
public func updateInstructions(_ newInstructions: String) {
// Create new conversation with updated instructions
let oldMessages = self.conversation.getModelMessages().filter { $0.role != .system }
self.conversation = Conversation()
self.conversation = Conversation(configuration: self.configuration)
self.conversation.addSystemMessage(newInstructions)
// Re-add non-system messages
@ -179,6 +292,30 @@ public final class Agent<Context>: @unchecked Sendable {
}
}
final class AsyncReleaseOnce: @unchecked Sendable {
private let lock = NSLock()
private var didRelease = false
private let operation: @Sendable () async -> Void
init(operation: @escaping @Sendable () async -> Void) {
self.operation = operation
}
func release() {
self.lock.lock()
guard !self.didRelease else {
self.lock.unlock()
return
}
self.didRelease = true
self.lock.unlock()
Task {
await self.operation()
}
}
}
/// Response from an agent execution
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct AgentResponse: Sendable {
@ -537,3 +674,33 @@ public enum SessionStatus: String, Codable, Sendable, CaseIterable {
case failed
case cancelled
}
extension LanguageModel {
var requiresTerminalRefusalBuffering: Bool {
switch self {
case let .anthropic(model):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: model.modelId)
case let .anthropicCompatible(modelId, _):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
case let .openRouter(modelId), let .together(modelId):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
case let .openaiCompatible(modelId, _):
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: modelId)
case let .custom(provider):
guard
let parsed = ProviderParser.parse(provider.modelId),
let registeredProvider = CustomProviderRegistry.shared.get(parsed.provider) else
{
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: provider.modelId)
}
switch registeredProvider.kind {
case .openai:
return false
case .anthropic:
return LanguageModel.Anthropic.hasStreamingRefusalRisk(modelId: parsed.model)
}
default:
return false
}
}
}

View File

@ -3,10 +3,66 @@ import Tachikoma
// MARK: - Conversation Management
private actor ContinuationGate {
private struct Waiter {
let id: UUID
let continuation: CheckedContinuation<Bool, Never>
}
private var isLocked = false
private var waiters: [Waiter] = []
func acquire() async throws {
try Task.checkCancellation()
if !self.isLocked {
self.isLocked = true
return
}
let id = UUID()
let acquired = await withTaskCancellationHandler {
await withCheckedContinuation { continuation in
self.waiters.append(Waiter(id: id, continuation: continuation))
}
} onCancel: {
Task { await self.cancelWaiter(id) }
}
guard acquired else {
throw CancellationError()
}
if Task.isCancelled {
self.release()
throw CancellationError()
}
}
private func cancelWaiter(_ id: UUID) {
guard let index = self.waiters.firstIndex(where: { $0.id == id }) else {
return
}
let waiter = self.waiters.remove(at: index)
waiter.continuation.resume(returning: false)
}
func release() {
if self.waiters.isEmpty {
self.isLocked = false
} else {
let waiter = self.waiters.removeFirst()
waiter.continuation.resume(returning: true)
}
}
}
/// A conversation with an AI model
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public final class Conversation: @unchecked Sendable {
private let lock = NSLock()
private let continuationGate = ContinuationGate()
private var _messages: [ConversationMessage] = []
/// The configuration used by this conversation
@ -59,7 +115,6 @@ public final class Conversation: @unchecked Sendable {
/// Get messages as ModelMessage array for API compatibility
public func getModelMessages() -> [ModelMessage] {
// Get messages as ModelMessage array for API compatibility
self.messages.map { $0.toModelMessage() }
}
@ -72,31 +127,193 @@ public final class Conversation: @unchecked Sendable {
self.lock.unlock()
}
/// Continue the conversation with a model
public func continueConversation(using model: Model? = nil, tools _: [AgentTool]? = nil) async throws -> String {
// Convert conversation messages to model messages
let modelMessages = self.messages.map { conversationMessage in
ModelMessage(
id: conversationMessage.id,
role: ModelMessage.Role(rawValue: conversationMessage.role.rawValue) ?? .user,
content: [.text(conversationMessage.content)],
timestamp: conversationMessage.timestamp,
/// Replace the conversation with lossless ModelMessage history.
public func replaceModelMessages(_ modelMessages: [ModelMessage]) {
self.lock.lock()
self._messages = modelMessages.map { ConversationMessage.from($0) }
self.lock.unlock()
}
/// Replace the conversation only if the original snapshot is still current.
public func replaceModelMessages(
_ modelMessages: [ModelMessage],
validatingSnapshotIDs snapshotIDs: [String],
)
-> Bool
{
self.lock.lock()
defer { self.lock.unlock() }
guard self._messages.count >= snapshotIDs.count else {
return false
}
let currentPrefixIDs = self._messages.prefix(snapshotIDs.count).map(\.id)
guard currentPrefixIDs == snapshotIDs else {
return false
}
let laterMessages = self._messages.dropFirst(snapshotIDs.count)
self._messages = modelMessages.map { ConversationMessage.from($0) } + laterMessages
return true
}
/// Replace the original snapshot with generated history while preserving later appends.
public func mergeGeneratedMessages(_ modelMessages: [ModelMessage], replacingPrefixCount prefixCount: Int) {
self.lock.lock()
let laterMessages = self._messages.dropFirst(min(prefixCount, self._messages.count))
self._messages = modelMessages.map { ConversationMessage.from($0) } + laterMessages
self.lock.unlock()
}
/// Insert generated response messages after the snapshot anchor while preserving concurrent appends.
public func appendGeneratedMessages(_ modelMessages: [ModelMessage], afterMessageID messageID: String) {
guard !modelMessages.isEmpty else { return }
self.lock.lock()
let conversationMessages = modelMessages.map { ConversationMessage.from($0) }
if let index = self._messages.firstIndex(where: { $0.id == messageID }) {
self._messages.insert(contentsOf: conversationMessages, at: self._messages.index(after: index))
} else {
self._messages.append(contentsOf: conversationMessages)
}
self.lock.unlock()
}
/// Insert generated response messages only if the snapshot prefix is still current.
public func appendGeneratedMessages(
_ modelMessages: [ModelMessage],
afterMessageID messageID: String,
validatingSnapshotIDs snapshotIDs: [String],
)
-> Bool
{
guard !modelMessages.isEmpty else { return true }
self.lock.lock()
defer { self.lock.unlock() }
guard self._messages.count >= snapshotIDs.count else {
return false
}
let currentPrefixIDs = self._messages.prefix(snapshotIDs.count).map(\.id)
guard currentPrefixIDs == snapshotIDs else {
return false
}
let conversationMessages = modelMessages.map { ConversationMessage.from($0) }
if let index = self._messages.firstIndex(where: { $0.id == messageID }) {
self._messages.insert(contentsOf: conversationMessages, at: self._messages.index(after: index))
} else {
self._messages.append(contentsOf: conversationMessages)
}
return true
}
/// Merge a refused generation without losing completed tool steps.
public func mergeContentFilterResult(
_ resultMessages: [ModelMessage],
originalMessages: [ModelMessage],
afterMessageID _: String?,
validatingSnapshotIDs snapshotIDs: [String],
)
-> Bool
{
let generatedMessages = Array(resultMessages.dropFirst(originalMessages.count))
if !generatedMessages.isEmpty {
return self.replaceModelMessages(
originalMessages + generatedMessages,
validatingSnapshotIDs: snapshotIDs,
)
}
// Generate response using the core API
let response = try await generateText(
model: model ?? .default,
messages: modelMessages,
tools: [],
settings: .default,
configuration: configuration,
return self.replaceModelMessages(
originalMessages.droppingLastUserTurn(),
validatingSnapshotIDs: snapshotIDs,
)
}
// Add the response to the conversation
self.addAssistantMessage(response.text)
public func removeMessage(id: String) {
self.lock.lock()
self._messages.removeAll { $0.id == id }
self.lock.unlock()
}
return response.text
public func withContinuationLock<T>(_ operation: () async throws -> T) async throws -> T {
try await self.acquireContinuationLock()
do {
let result = try await operation()
await self.releaseContinuationLock()
return result
} catch {
await self.releaseContinuationLock()
throw error
}
}
public func acquireContinuationLock() async throws {
try await self.continuationGate.acquire()
}
public func releaseContinuationLock() async {
await self.continuationGate.release()
}
/// Continue the conversation with a model
public func continueConversation(
using model: Model? = nil,
tools: [AgentTool]? = nil,
maxSteps: Int = 5,
) async throws
-> String
{
try await self.withContinuationLock {
let conversationMessages = self.messages
let modelMessages = conversationMessages.map { $0.toModelMessage() }
let snapshotIDs = conversationMessages.map(\.id)
let anchorID = conversationMessages.last?.id
// Generate response using the core API
let response = try await generateText(
model: model ?? .default,
messages: modelMessages,
tools: tools,
settings: .default,
maxSteps: maxSteps,
configuration: configuration,
)
let didMerge: Bool
if response.finishReason == .contentFilter {
didMerge = self.mergeContentFilterResult(
response.messages,
originalMessages: modelMessages,
afterMessageID: anchorID,
validatingSnapshotIDs: snapshotIDs,
)
} else if let anchorID {
let generatedMessages = Array(response.messages.dropFirst(modelMessages.count))
didMerge = self.appendGeneratedMessages(
generatedMessages,
afterMessageID: anchorID,
validatingSnapshotIDs: snapshotIDs,
)
} else if self.messages.isEmpty {
self.replaceModelMessages(response.messages)
didMerge = true
} else {
didMerge = false
}
guard didMerge else {
throw TachikomaError.invalidConfiguration(
"Conversation changed during generation; refusing to merge response",
)
}
return response.text
}
}
/// Continue the conversation with a model, streaming the response
@ -106,43 +323,90 @@ public final class Conversation: @unchecked Sendable {
) async throws
-> AsyncThrowingStream<String, Error>
{
// Convert conversation messages to model messages
let modelMessages = self.messages.map { conversationMessage in
ModelMessage(
id: conversationMessage.id,
role: ModelMessage.Role(rawValue: conversationMessage.role.rawValue) ?? .user,
content: [.text(conversationMessage.content)],
timestamp: conversationMessage.timestamp,
)
try await self.acquireContinuationLock()
let gateRelease = AsyncReleaseOnce {
await self.releaseContinuationLock()
}
let conversationMessages = self.messages
let modelMessages = conversationMessages.map { $0.toModelMessage() }
let snapshotIDs = conversationMessages.map(\.id)
let resolvedModel = model ?? .defaultStreaming
let streamSettings = GenerationSettings.default
let buffersUntilDone = streamSettings.streamBuffering == .untilTerminal ||
resolvedModel.requiresTerminalRefusalBuffering
// Generate response using the core API
let responseStream = try await streamText(
model: model ?? .default,
messages: modelMessages,
tools: tools ?? [], // Use provided tools or empty array
settings: .default,
configuration: configuration,
)
let responseStream: StreamTextResult
do {
responseStream = try await streamText(
model: resolvedModel,
messages: modelMessages,
tools: tools ?? [], // Use provided tools or empty array
settings: streamSettings,
configuration: self.configuration,
)
} catch {
gateRelease.release()
throw error
}
// Create a new stream to process the response and update the conversation
return AsyncThrowingStream<String, Error> { continuation in
Task {
let producer = Task {
defer {
gateRelease.release()
}
var fullResponse = ""
var isContentFiltered = false
var bufferedText: [String] = []
var didApproveBufferedResponse = !buffersUntilDone
var didReceiveTerminal = false
do {
for try await delta in responseStream.stream {
try Task.checkCancellation()
switch delta.type {
case .textDelta:
if let text = delta.content {
continuation.yield(text)
if buffersUntilDone {
bufferedText.append(text)
} else {
continuation.yield(text)
}
fullResponse += text
}
case .done where delta.finishReason == .contentFilter:
didReceiveTerminal = true
isContentFiltered = true
let didRollback = self.replaceModelMessages(
modelMessages.droppingLastUserTurn(),
validatingSnapshotIDs: snapshotIDs,
)
guard didRollback else {
throw TachikomaError.invalidConfiguration(
"Conversation changed during streaming; refusing to merge response",
)
}
fullResponse = ""
bufferedText.removeAll()
case .done:
didReceiveTerminal = true
if buffersUntilDone {
for text in bufferedText {
continuation.yield(text)
}
didApproveBufferedResponse = true
bufferedText.removeAll()
}
default:
break
}
}
if buffersUntilDone, !didReceiveTerminal, !bufferedText.isEmpty {
throw TachikomaError.apiError("Stream ended before provider completion status was received")
}
// Add the full response to the conversation
if !fullResponse.isEmpty {
if !isContentFiltered, !fullResponse.isEmpty, didApproveBufferedResponse {
try Task.checkCancellation()
self.addAssistantMessage(fullResponse)
}
continuation.finish()
@ -150,10 +414,20 @@ public final class Conversation: @unchecked Sendable {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
producer.cancel()
}
}
}
}
extension [ModelMessage] {
func droppingLastUserTurn() -> [ModelMessage] {
guard self.last?.role == .user else { return self }
return Array(self.dropLast())
}
}
/// A message in a conversation
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct ConversationMessage: Sendable, Codable, Equatable {
@ -161,6 +435,9 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
public let role: Role
public let content: String
public let timestamp: Date
public let contentParts: [ModelMessage.ContentPart]?
public let channel: ResponseChannel?
public let metadata: MessageMetadata?
public enum Role: String, Sendable, Codable, CaseIterable {
case system
@ -169,11 +446,22 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
case tool
}
public init(id: String = UUID().uuidString, role: Role, content: String, timestamp: Date = Date()) {
public init(
id: String = UUID().uuidString,
role: Role,
content: String,
timestamp: Date = Date(),
contentParts: [ModelMessage.ContentPart]? = nil,
channel: ResponseChannel? = nil,
metadata: MessageMetadata? = nil,
) {
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
self.contentParts = contentParts
self.channel = channel
self.metadata = metadata
}
/// Convert to ModelMessage for API compatibility
@ -189,8 +477,10 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
return ModelMessage(
id: self.id,
role: modelRole,
content: [.text(self.content)],
content: self.contentParts ?? [.text(self.content)],
timestamp: self.timestamp,
channel: self.channel,
metadata: self.metadata,
)
}
@ -219,6 +509,9 @@ public struct ConversationMessage: Sendable, Codable, Equatable {
role: role,
content: textContent,
timestamp: modelMessage.timestamp,
contentParts: modelMessage.content,
channel: modelMessage.channel,
metadata: modelMessage.metadata,
)
}
}

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-4o-realtime-preview")
/// Model to use (e.g., "gpt-realtime")
public var model: String
/// Voice for audio responses
@ -213,7 +213,7 @@ public struct SessionConfiguration: Sendable, Codable {
}
public init(
model: String = "gpt-4o-realtime-preview",
model: String = "gpt-realtime",
voice: RealtimeVoice = .alloy,
instructions: String? = nil,
inputAudioFormat: RealtimeAudioFormat = .pcm16,
@ -242,7 +242,7 @@ public struct SessionConfiguration: Sendable, Codable {
/// Create a default configuration for voice conversations
public static func voiceConversation(
model: String = "gpt-4o-realtime-preview",
model: String = "gpt-realtime",
voice: RealtimeVoice = .alloy,
)
-> SessionConfiguration
@ -258,7 +258,7 @@ public struct SessionConfiguration: Sendable, Codable {
/// Create a configuration for text-only interactions
public static func textOnly(
model: String = "gpt-4o-realtime-preview",
model: String = "gpt-realtime",
)
-> SessionConfiguration
{
@ -273,7 +273,7 @@ public struct SessionConfiguration: Sendable, Codable {
/// Create a configuration with tools
public static func withTools(
model: String = "gpt-4o-realtime-preview",
model: String = "gpt-realtime",
voice: RealtimeVoice = .alloy,
tools: [RealtimeTool],
)

View File

@ -150,7 +150,7 @@ public final class RealtimeConversation: ObservableObject {
// Create session with configuration
let sessionConfig = SessionConfiguration(
model: "gpt-4o-realtime-preview",
model: "gpt-realtime",
voice: .alloy,
instructions: nil,
tools: nil,
@ -167,7 +167,7 @@ public final class RealtimeConversation: ObservableObject {
/// Start the conversation
public func start(
model: LanguageModel.OpenAI = .gpt4oRealtime,
model: LanguageModel.OpenAI = .custom("gpt-realtime"),
voice: RealtimeVoice = .alloy,
instructions: String? = nil,
tools: [RealtimeTool]? = nil,
@ -495,7 +495,7 @@ public final class RealtimeConversation: ObservableObject {
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public func startRealtimeConversation(
model: LanguageModel.OpenAI = .gpt4oRealtime,
model: LanguageModel.OpenAI = .custom("gpt-realtime"),
voice: RealtimeVoice = .alloy,
instructions: String? = nil,
tools: [AgentTool]? = nil,
@ -548,7 +548,7 @@ public final class RealtimeConversation {
}
public func start(
model _: LanguageModel.OpenAI = .gpt4oRealtime,
model _: LanguageModel.OpenAI = .custom("gpt-realtime"),
voice _: RealtimeVoice = .alloy,
instructions _: String? = nil,
tools _: [RealtimeTool]? = nil,
@ -560,7 +560,7 @@ public final class RealtimeConversation {
// swiftformat:disable wrapMultilineStatementBraces wrapReturnType indent
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public func startRealtimeConversation(
model _: LanguageModel.OpenAI = .gpt4oRealtime,
model _: LanguageModel.OpenAI = .custom("gpt-realtime"),
voice _: RealtimeVoice = .alloy,
instructions _: String? = nil,
tools _: [AgentTool]? = nil,

View File

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

View File

@ -16,20 +16,16 @@ public enum TranscriptionModel: Sendable, CustomStringConvertible {
public enum OpenAI: String, CaseIterable, Sendable {
case whisper1 = "whisper-1"
case gpt4oTranscribe = "gpt-4o-transcribe"
case gpt4oMiniTranscribe = "gpt-4o-mini-transcribe"
public var supportsTimestamps: Bool {
switch self {
case .whisper1: true
case .gpt4oTranscribe, .gpt4oMiniTranscribe: false
}
}
public var supportsLanguageDetection: Bool {
switch self {
case .whisper1: true
case .gpt4oTranscribe, .gpt4oMiniTranscribe: false
}
}
}
@ -155,12 +151,10 @@ public enum SpeechModel: Sendable, CustomStringConvertible {
public enum OpenAI: String, CaseIterable, Sendable {
case tts1 = "tts-1"
case tts1HD = "tts-1-hd"
case gpt4oMiniTTS = "gpt-4o-mini-tts"
public var supportsVoiceInstructions: Bool {
switch self {
case .tts1, .tts1HD: false
case .gpt4oMiniTTS: true
}
}
@ -226,5 +220,5 @@ public enum SpeechModel: Sendable, CustomStringConvertible {
public static let `default`: SpeechModel = .openai(.tts1)
public static let highQuality: SpeechModel = .openai(.tts1HD)
public static let fast: SpeechModel = .openai(.tts1)
public static let expressive: SpeechModel = .openai(.gpt4oMiniTTS)
public static let expressive: SpeechModel = .openai(.tts1HD)
}

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] {
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini, .openrouter] {
let status = await TKConfigCLI.status(for: pid, timeout: timeout)
print(" \(pid.displayName): \(status)")
}
@ -150,6 +150,8 @@ struct TKConfigCLI {
}
case .gemini:
if let v = env["GEMINI_API_KEY"], !v.isEmpty { return ("GEMINI_API_KEY", v) }
case .openrouter:
if let v = env["OPENROUTER_API_KEY"], !v.isEmpty { return ("OPENROUTER_API_KEY", v) }
}
return nil
}
@ -169,6 +171,8 @@ struct TKConfigCLI {
}
case .gemini:
if let v = manager.credentialValue(for: "GEMINI_API_KEY") { return ("GEMINI_API_KEY", v) }
case .openrouter:
if let v = manager.credentialValue(for: "OPENROUTER_API_KEY") { return ("OPENROUTER_API_KEY", v) }
}
return nil
}

View File

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

View File

@ -12,7 +12,7 @@ import Tachikoma
// Use Tachikoma normally without MCP
let result = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: messages,
tools: staticTools
)
@ -26,7 +26,7 @@ import TachikomaMCP
// Now you can use MCP tools alongside static tools
let mcpTools = try await MCPToolDiscovery.withFilesystem()
let result = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: messages,
tools: staticTools + mcpTools
)

View File

@ -191,7 +191,7 @@ struct MCPClientTests {
let imageData = Data("test".utf8)
let imageResponse = ToolResponse.image(data: imageData, mimeType: "image/png")
#expect(imageResponse.content.count == 1)
if case .image(data: let data, mimeType: let mimeType, annotations: _, _meta: _) = imageResponse.content.first {
if case let .image(data: data, mimeType: mimeType, annotations: _, _meta: _) = imageResponse.content.first {
#expect(data == imageData.base64EncodedString())
#expect(mimeType == "image/png")
} else {

View File

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

View File

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

View File

@ -81,43 +81,47 @@ struct AudioProviderFactoryTests {
}
@Test
func `AudioConfiguration reads configuration keys before environment`() {
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
unsetenv("OPENAI_API_KEY")
defer {
if let previousOpenAIKey {
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
func `AudioConfiguration reads configuration keys before environment`() async {
await TestEnvironmentMutex.shared.withLock {
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
unsetenv("OPENAI_API_KEY")
defer {
if let previousOpenAIKey {
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
}
}
let configuration = TachikomaConfiguration()
configuration.setAPIKey("configured-key", for: "openai")
let resolvedKey = AudioConfiguration.getAPIKey(for: "openai", configuration: configuration)
#expect(resolvedKey == "configured-key")
}
let configuration = TachikomaConfiguration()
configuration.setAPIKey("configured-key", for: "openai")
let resolvedKey = AudioConfiguration.getAPIKey(for: "openai", configuration: configuration)
#expect(resolvedKey == "configured-key")
}
@Test
func `AudioConfiguration falls back to environment variable`() {
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
setenv("OPENAI_API_KEY", "env-key-123", 1)
defer {
if let previousOpenAIKey {
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
} else {
unsetenv("OPENAI_API_KEY")
func `AudioConfiguration falls back to environment variable`() async {
await TestEnvironmentMutex.shared.withLock {
let previousOpenAIKey = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
setenv("OPENAI_API_KEY", "env-key-123", 1)
defer {
if let previousOpenAIKey {
setenv("OPENAI_API_KEY", previousOpenAIKey, 1)
} else {
unsetenv("OPENAI_API_KEY")
}
}
}
let expectedKey = getenv("OPENAI_API_KEY").map { String(cString: $0) }
let resolvedKey = AudioConfiguration.getAPIKey(
for: "openai",
configuration: TachikomaConfiguration(loadFromEnvironment: false),
)
if let expectedKey {
#expect(resolvedKey == expectedKey)
} else {
Issue.record("Expected environment override for OPENAI_API_KEY")
let expectedKey = getenv("OPENAI_API_KEY").map { String(cString: $0) }
let resolvedKey = AudioConfiguration.getAPIKey(
for: "openai",
configuration: TachikomaConfiguration(loadFromEnvironment: false),
)
if let expectedKey {
#expect(resolvedKey == expectedKey)
} else {
Issue.record("Expected environment override for OPENAI_API_KEY")
}
}
}
}

View File

@ -44,6 +44,7 @@ struct AuthManagerTests {
unsetenv("ANTHROPIC_ACCESS_TOKEN")
unsetenv("GEMINI_API_KEY")
unsetenv("GOOGLE_API_KEY")
unsetenv("OPENROUTER_API_KEY")
}
@Test
@ -79,6 +80,29 @@ struct AuthManagerTests {
}
}
@Test
func `openrouter resolves env and credential keys as bearer auth`() async throws {
try await self.withIsolatedAuthState {
self.resetAuthEnv()
try TKAuthManager.shared.setCredential(key: "OPENROUTER_API_KEY", value: "credential-openrouter-key")
guard case let .bearer(credentialToken, _)? = TKAuthManager.shared.resolveAuth(for: .openrouter) else {
Issue.record("Expected OpenRouter bearer auth from credentials")
return
}
#expect(credentialToken == "credential-openrouter-key")
setenv("OPENROUTER_API_KEY", "env-openrouter-key", 1)
defer { unsetenv("OPENROUTER_API_KEY") }
guard case let .bearer(envToken, _)? = TKAuthManager.shared.resolveAuth(for: .openrouter) else {
Issue.record("Expected OpenRouter bearer auth from environment")
return
}
#expect(envToken == "env-openrouter-key")
}
}
@Test
@MainActor
func `validate success mock`() async throws {

View File

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

View File

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

View File

@ -33,4 +33,17 @@ struct ConfigurationEnvironmentTests {
let configuration = TachikomaConfiguration(loadFromEnvironment: true)
#expect(configuration.getBaseURL(for: .openai) == "https://env.example.com")
}
@Test
func `TachikomaConfiguration picks up ANTHROPIC_BASE_URL from environment`() {
let key = "ANTHROPIC_BASE_URL"
setenv(key, "https://env.example.com", 1)
defer { unsetenv(key) }
let rawValue = Provider.environmentValue(for: key)
#expect(rawValue == "https://env.example.com")
let configuration = TachikomaConfiguration(loadFromEnvironment: true)
#expect(configuration.getBaseURL(for: .anthropic) == "https://env.example.com")
}
}

View File

@ -0,0 +1,164 @@
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
import Foundation
import Testing
@testable import Tachikoma
@Suite(.serialized)
struct CredentialLoadingTests {
@Test
func `OAuth tokens are not loaded as OpenAI API keys`() async throws {
try await self.withIsolatedCredentials(
"""
OPENAI_ACCESS_TOKEN=access-token
OPENAI_REFRESH_TOKEN=refresh-token
OPENAI_ACCESS_EXPIRES=4102444800
""",
) {
let config = TachikomaConfiguration(loadFromEnvironment: true)
#expect(config.getAPIKey(for: .openai) == nil)
}
}
@Test
func `OpenAI API key credential is preferred over OAuth token noise`() async throws {
try await self.withIsolatedCredentials(
"""
OPENAI_ACCESS_TOKEN=access-token
OPENAI_API_KEY=api-key
OPENAI_REFRESH_TOKEN=refresh-token
""",
) {
let config = TachikomaConfiguration(loadFromEnvironment: true)
#expect(config.getAPIKey(for: .openai) == "api-key")
}
}
@Test
func `Absolute profile path credentials load without HOME`() async throws {
#if !os(Windows)
try await TestEnvironmentMutex.shared.withLock {
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
let profilePath = FileManager.default.temporaryDirectory
.appendingPathComponent("tachikoma-absolute-credentials-\(UUID().uuidString)")
.path
let credentialPath = "\(profilePath)/credentials"
let savedHome = getenv("HOME").map { String(cString: $0) }
let savedEnvironment = self.unsetOpenAIEnvironment() + [("HOME", savedHome)]
TachikomaConfiguration.profileDirectoryName = profilePath
try FileManager.default.createDirectory(atPath: profilePath, withIntermediateDirectories: true)
try "OPENAI_API_KEY=absolute-api-key\n".write(toFile: credentialPath, atomically: true, encoding: .utf8)
unsetenv("HOME")
defer {
self.restoreEnvironment(savedEnvironment)
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
try? FileManager.default.removeItem(atPath: profilePath)
}
let config = TachikomaConfiguration(loadFromEnvironment: true)
#expect(config.getAPIKey(for: .openai) == "absolute-api-key")
}
#endif
}
@Test
func `MiniMax China credentials save and reload with canonical env name`() async throws {
#if !os(Windows)
try await TestEnvironmentMutex.shared.withLock {
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
let profilePath = FileManager.default.temporaryDirectory
.appendingPathComponent("tachikoma-minimax-cn-credentials-\(UUID().uuidString)")
.path
let credentialPath = "\(profilePath)/credentials"
let savedEnvironment = self.savedEnvironment(for: ["MINIMAX_CN_API_KEY", "MINIMAX_API_KEY"])
TachikomaConfiguration.profileDirectoryName = profilePath
for (key, _) in savedEnvironment {
unsetenv(key)
}
defer {
self.restoreEnvironment(savedEnvironment)
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
try? FileManager.default.removeItem(atPath: profilePath)
}
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("cn-api-key", for: .minimaxCN)
try config.saveCredentials()
let savedCredentials = try String(contentsOfFile: credentialPath, encoding: .utf8)
#expect(savedCredentials.contains("MINIMAX_CN_API_KEY=cn-api-key"))
#expect(!savedCredentials.contains("MINIMAX-CN_API_KEY"))
let reloaded = TachikomaConfiguration(loadFromEnvironment: true)
#expect(reloaded.getAPIKey(for: .minimaxCN) == "cn-api-key")
}
#endif
}
@Test
func `MiniMax China availability accepts configured shared MiniMax key`() {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("shared-minimax-key", for: .minimax)
#expect(config.getAPIKey(for: .minimaxCN) == "shared-minimax-key")
#expect(config.hasAPIKey(for: .minimaxCN))
}
private func withIsolatedCredentials<T: Sendable>(
_ credentials: String,
_ body: @Sendable () throws -> T,
) async throws
-> T
{
try await TestEnvironmentMutex.shared.withLock {
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
let profileDirectory = ".tachikoma-credential-tests-\(UUID().uuidString)"
let homeDirectory = try #require(ProcessInfo.processInfo.environment["HOME"])
let profilePath = "\(homeDirectory)/\(profileDirectory)"
let credentialPath = "\(profilePath)/credentials"
let savedEnvironment = self.unsetOpenAIEnvironment()
TachikomaConfiguration.profileDirectoryName = profileDirectory
try FileManager.default.createDirectory(atPath: profilePath, withIntermediateDirectories: true)
try credentials.write(toFile: credentialPath, atomically: true, encoding: .utf8)
defer {
self.restoreEnvironment(savedEnvironment)
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
try? FileManager.default.removeItem(atPath: profilePath)
}
return try body()
}
}
private func unsetOpenAIEnvironment() -> [(String, String?)] {
let keys = ["OPENAI_API_KEY", "OPENAI_ACCESS_TOKEN", "OPENAI_REFRESH_TOKEN", "OPENAI_ACCESS_EXPIRES"]
let saved = self.savedEnvironment(for: keys)
keys.forEach { unsetenv($0) }
return saved
}
private func savedEnvironment(for keys: [String]) -> [(String, String?)] {
keys.map { key in
(key, getenv(key).map { String(cString: $0) })
}
}
private func restoreEnvironment(_ saved: [(String, String?)]) {
for (key, value) in saved {
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
}
}
}

View File

@ -38,7 +38,8 @@ struct CustomProviderRegistryTests {
/* secret token */
"headers": {
"Authorization": "Bearer ${WEATHER_TOKEN}"
}
},
"apiKey": "${WEATHER_API_KEY}"
},
"models": {
"fast": { "name": "weather-fast" }
@ -46,7 +47,10 @@ struct CustomProviderRegistryTests {
},
"claude-proxy": {
"type": "anthropic",
"options": { "baseURL": "https://anthropic.local" }
"options": {
"baseURL": "https://anthropic.local",
"apiKey": "claude-provider-key"
}
}
}
}
@ -68,6 +72,7 @@ struct CustomProviderRegistryTests {
#endif
TachikomaConfiguration.profileDirectoryName = originalProfileDir
unsetenv("WEATHER_TOKEN")
unsetenv("WEATHER_API_KEY")
self.resetRegistry(
forProfile: originalProfileDir,
originalHome: originalHome,
@ -78,6 +83,7 @@ struct CustomProviderRegistryTests {
TachikomaConfiguration.profileDirectoryName = profileDirName
self.setHomeEnvironment(to: tempHome.path)
setenv("WEATHER_TOKEN", "sk-test-weather", 1)
setenv("WEATHER_API_KEY", "sk-provider-key", 1)
CustomProviderRegistry.shared.loadFromProfile()
let providers = CustomProviderRegistry.shared.list()
@ -87,14 +93,57 @@ struct CustomProviderRegistryTests {
let weather = try #require(providers["weather-ai"])
#expect(weather.kind == .openai)
#expect(weather.baseURL == "https://api.example.com")
#expect(weather.apiKey == "sk-provider-key")
#expect(weather.headers["Authorization"] == "Bearer sk-test-weather")
#expect(weather.models["fast"] == "weather-fast")
let claude = try #require(CustomProviderRegistry.shared.get("claude-proxy"))
#expect(claude.kind == .anthropic)
#expect(claude.apiKey == "claude-provider-key")
#expect(claude.headers.isEmpty)
#expect(claude.models.isEmpty)
let dynamicProvider = DynamicCustomProvider(modelId: "weather-ai/fast")
let resolved = try ProviderFactory.createProvider(
for: .custom(provider: dynamicProvider),
configuration: TachikomaConfiguration(loadFromEnvironment: false),
)
let compatibleProvider = try #require(resolved as? OpenAICompatibleProvider)
#expect(compatibleProvider.apiKey == "sk-provider-key")
#expect(compatibleProvider.additionalHeaders["Authorization"] == "Bearer sk-test-weather")
let claudeProvider = DynamicCustomProvider(modelId: "claude-proxy/sonnet")
let resolvedClaude = try ProviderFactory.createProvider(
for: .custom(provider: claudeProvider),
configuration: TachikomaConfiguration(loadFromEnvironment: false),
)
let compatibleClaude = try #require(resolvedClaude as? AnthropicCompatibleProvider)
#expect(compatibleClaude.apiKey == "claude-provider-key")
let fableModel = LanguageModel.custom(
provider: DynamicCustomProvider(modelId: "claude-proxy/claude-fable-5"),
)
#expect(fableModel.supportsStreaming == false)
let resolvedFable = try ProviderFactory.createProvider(
for: fableModel,
configuration: TachikomaConfiguration(loadFromEnvironment: false),
)
let compatibleFable = try #require(resolvedFable as? AnthropicCompatibleProvider)
#expect(compatibleFable.capabilities.supportsStreaming == false)
let directFableProvider = DynamicCustomProvider(
modelId: "claude-fable-5",
capabilities: ModelCapabilities(supportsStreaming: false),
)
let directFableModel = LanguageModel.custom(provider: directFableProvider)
#expect(directFableModel.supportsStreaming == false)
let unrelatedFableNamedProvider = DynamicCustomProvider(
modelId: "local-claude-fable-5-benchmark",
capabilities: ModelCapabilities(supportsStreaming: true),
)
#expect(LanguageModel.custom(provider: unrelatedFableNamedProvider).supportsStreaming == true)
#expect(CustomProviderRegistry.shared.get("missing") == nil)
}
@ -145,3 +194,25 @@ struct CustomProviderRegistryTests {
#endif
}
}
private final class DynamicCustomProvider: ModelProvider {
let modelId: String
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities: ModelCapabilities
init(modelId: String, capabilities: ModelCapabilities = ModelCapabilities()) {
self.modelId = modelId
self.capabilities = capabilities
}
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "")
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
AsyncThrowingStream { continuation in
continuation.finish()
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,17 +4,9 @@ import Testing
struct GrokModelCatalogTests {
private static let catalog: [Model.Grok] = [
.grok4,
.grok4FastReasoning,
.grok4FastNonReasoning,
.grokCodeFast1,
.grok3,
.grok3Mini,
.grok2,
.grok2Vision,
.grok2Image,
.grokVisionBeta,
.grokBeta,
.grok43,
.grok420Reasoning,
.grok420NonReasoning,
]
private func requireModernPlatforms(_ body: () throws -> Void) rethrows {
@ -39,6 +31,7 @@ struct GrokModelCatalogTests {
let parsed = try ModelSelector.parseModel(model.modelId)
#expect(parsed == .grok(model))
}
#expect(try ModelSelector.parseModel("grok-4.3-latest") == .grok(.grok43))
}
}
@ -52,13 +45,69 @@ struct GrokModelCatalogTests {
}
@Test
func `Vision capability only flips on for vision/image Grok models`() {
func `Grok model vision support matches current xAI catalog`() {
self.requireModernPlatforms {
let visionModels: Set<Model.Grok> = [.grok2Vision, .grok2Image, .grokVisionBeta]
#expect(Model.grok(.grok43).supportsVision)
#expect(Model.grok(.grok420Reasoning).supportsVision)
#expect(Model.grok(.grok420NonReasoning).supportsVision)
#expect(Model.grok(.grok420MultiAgent).supportsVision == false)
#expect(Model.grok(.grok420MultiAgent).supportsTools == false)
}
}
for model in Self.catalog {
let languageModel = Model.grok(model)
#expect(languageModel.supportsVision == visionModels.contains(model))
@Test
func `ModelSelector preserves server-redirected Grok identifiers`() throws {
try self.requireModernPlatforms {
for id in [
"grok-4-0709",
"grok-3",
"grok-2-1212",
"grok-4-fast",
"grok-code-fast-1",
] {
let parsed = try ModelSelector.parseModel(id)
#expect(parsed == .grok(.custom(id)))
}
}
}
@Test
func `ModelSelector keeps provider-qualified Grok slugs on xAI`() throws {
try self.requireModernPlatforms {
let parsed = try ModelSelector.parseModel("xai/grok-code-fast-1")
#expect(parsed == .grok(.custom("grok-code-fast-1")))
}
}
@Test
func `ModelSelector rejects unsupported Grok multi-agent identifiers`() {
self.requireModernPlatforms {
for id in [
"grok-4.20-multi-agent-0309",
"grok420multiagent",
"xai/grok-4.20-multi-agent",
] {
#expect(throws: ModelValidationError.self) {
_ = try ModelSelector.parseModel(id)
}
}
}
}
@Test
func `Grok provider rejects multi-agent until Responses routing exists`() {
self.requireModernPlatforms {
let config = TachikomaConfiguration(apiKeys: ["grok": "test-key"])
#expect(throws: TachikomaError.self) {
_ = try ProviderFactory.createProvider(for: .grok(.grok420MultiAgent), configuration: config)
}
#expect(throws: TachikomaError.self) {
_ = try GrokProvider(model: .grok420MultiAgent, configuration: config)
}
#expect(throws: TachikomaError.self) {
_ = try GrokProvider(model: .custom("grok420multiagent"), configuration: config)
}
}
}

View File

@ -6,6 +6,7 @@ struct LanguageModelCoverageTests {
func `OpenAI enum exposes properties`() {
let models = LanguageModel.OpenAI.allCases
#expect(!models.isEmpty)
#expect(models.contains(.gpt5ChatLatest))
for model in models {
#expect(!model.modelId.isEmpty)
_ = model.supportsVision
@ -52,6 +53,13 @@ struct LanguageModelCoverageTests {
#expect(model.contextLength > 0)
}
for model in LanguageModel.MiniMax.allCases {
#expect(!model.modelId.isEmpty)
_ = model.supportsVision
_ = model.supportsTools
#expect(model.contextLength > 0)
}
for model in LanguageModel.Ollama.allCases {
#expect(!model.modelId.isEmpty)
_ = model.supportsVision
@ -68,12 +76,14 @@ struct LanguageModelCoverageTests {
@Test
func `LanguageModel top level switches`() {
let baseModels: [LanguageModel] = [
.openai(.gpt51),
.anthropic(.opus45),
.google(.gemini25Flash),
.mistral(.large2),
.groq(.mixtral8x7b),
.grok(.grok4),
.openai(.gpt55),
.anthropic(.fable5),
.google(.gemini35Flash),
.mistral(.medium35),
.groq(.llama3370b),
.grok(.grok43),
.minimax(.m27),
.minimaxCN(.m27),
.ollama(.llama33),
.lmstudio(.gptOSS20B),
.openRouter(modelId: "openrouter/alpha"),

View File

@ -9,9 +9,9 @@ struct MinimalModernAPITests {
@Test
func `Model enum construction`() {
// Test that model enums can be constructed
let openaiModel = Model.openai(.gpt4o)
let anthropicModel = Model.anthropic(.opus45)
_ = Model.grok(.grok4)
let openaiModel = Model.openai(.gpt55)
let anthropicModel = Model.anthropic(.opus48)
_ = Model.grok(.grok43)
_ = Model.ollama(.llama33)
// Test that they can be used in a switch statement
@ -35,13 +35,131 @@ struct MinimalModernAPITests {
let defaultModel = Model.default
// Should compile without errors
switch defaultModel {
case .anthropic(.opus45):
case .anthropic(.opus48):
break // Expected default
default:
Issue.record("Expected default to be Anthropic Opus 4.5")
Issue.record("Expected default to be Anthropic Opus 4.8")
}
}
@Test
func `Streaming default value`() {
#expect(Model.default.supportsStreaming == false)
#expect(Model.defaultStreaming == .openai(.gpt55))
#expect(Model.defaultStreaming.supportsStreaming == true)
}
@Test
func `Agent default model preserves execution default`() {
let agent = Agent(name: "test", instructions: "test", context: ())
#expect(agent.model == .default)
}
@Test
func `Agent stream uses streaming fallback for execution default`() async throws {
let seenModel = MinimalModelBox()
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { model, _ in
seenModel.model = model
return MinimalStreamingProvider(deltas: [
.text("ok"),
.done(finishReason: .stop),
])
}
let agent = Agent(name: "test", instructions: "test", configuration: config, context: ())
let stream = try await agent.stream("hi")
var received = ""
for try await delta in stream where delta.type == .textDelta {
received += delta.content ?? ""
}
#expect(agent.model == .default)
#expect(seenModel.model == .openai(.gpt55))
#expect(!received.isEmpty)
}
@Test
func `Agent stream rejects explicit execution default`() async throws {
let agent = Agent(name: "test", instructions: "test", model: .default, context: ())
await #expect(throws: TachikomaError.self) {
_ = try await agent.stream("hi")
}
}
@Test
func `Agent stream rejects nonstreaming model after mutation`() async throws {
let agent = Agent(name: "test", instructions: "test", context: ())
agent.model = .anthropic(.fable5)
await #expect(throws: TachikomaError.self) {
_ = try await agent.stream("hi")
}
}
@Test
func `Agent stream flushes buffered text on natural completion`() async throws {
let provider = MinimalStreamingProvider(deltas: [
.text("ok"),
])
let agent = Agent(
name: "test",
instructions: "test",
model: .custom(provider: provider),
context: (),
)
let stream = try await agent.stream("hi")
var received = ""
for try await delta in stream where delta.type == .textDelta {
received += delta.content ?? ""
}
#expect(received == "ok")
}
@Test
func `Agent stream flushes buffered compatible text when done has no finish reason`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("ok"),
.done(),
])
}
let agent = Agent(
name: "test",
instructions: "test",
model: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
configuration: config,
context: (),
)
let stream = try await agent.stream("hi")
var received = ""
for try await delta in stream where delta.type == .textDelta {
received += delta.content ?? ""
}
#expect(received == "ok")
#expect(agent.conversation.messages.map(\.content) == ["test", "hi", "ok"])
}
@Test
func `Agent conversation uses agent configuration`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStaticProvider(response: ProviderResponse(text: "configured", finishReason: .stop))
}
let agent = Agent(name: "test", instructions: "test", configuration: config, context: ())
let text = try await agent.conversation.continueConversation(using: .openai(.gpt55))
#expect(text == "configured")
}
// MARK: - Tool System Tests
@Test
@ -126,4 +244,757 @@ extension MinimalModernAPITests {
#expect(message.role == .user)
#expect(message.content == "Test")
}
@Test
func `Conversation preserves signed thinking messages`() {
let conversation = Conversation()
let signedThinking = ModelMessage(
role: .assistant,
content: [.text("private reasoning")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.signature": "sig",
"anthropic.thinking.type": "thinking",
]),
)
conversation.replaceModelMessages([.user("hi"), signedThinking, .assistant("hello")])
let messages = conversation.getModelMessages()
#expect(messages.count == 3)
#expect(messages[1] == signedThinking)
#expect(conversation.messages[1].content == "private reasoning")
}
@Test
func `Conversation merge preserves messages appended after snapshot`() {
let conversation = Conversation()
conversation.addUserMessage("original")
let snapshotCount = conversation.messages.count
conversation.addUserMessage("concurrent")
conversation.mergeGeneratedMessages(
[.user("original"), .assistant("generated")],
replacingPrefixCount: snapshotCount,
)
let messages = conversation.getModelMessages()
#expect(messages.map(\.role) == [.user, .assistant, .user])
if case let .text(text) = messages[2].content.first {
#expect(text == "concurrent")
} else {
Issue.record("Expected preserved concurrent user message")
}
}
@Test
func `Conversation refusal rollback preserves messages appended after snapshot`() {
let conversation = Conversation()
conversation.addUserMessage("blocked")
let snapshotIDs = conversation.messages.map(\.id)
conversation.addUserMessage("concurrent")
let didReplace = conversation.replaceModelMessages([], validatingSnapshotIDs: snapshotIDs)
#expect(didReplace == true)
#expect(conversation.messages.map(\.content) == ["concurrent"])
}
@Test
func `Conversation lock removes cancelled waiters`() async throws {
let conversation = Conversation()
let probe = ConversationLockProbe()
let first = Task {
try await conversation.withContinuationLock {
await probe.markFirstStarted()
await probe.waitForRelease()
}
}
await probe.waitUntilFirstStarted()
let second = Task {
try await conversation.withContinuationLock {
await probe.markSecondRan()
}
}
try await Task.sleep(nanoseconds: 10_000_000)
second.cancel()
do {
try await second.value
Issue.record("Expected queued waiter to be cancelled")
} catch is CancellationError {
// Expected
}
await probe.releaseFirst()
try await first.value
try await conversation.withContinuationLock {
await probe.markThirdRan()
}
#expect(await probe.secondRan == false)
#expect(await probe.thirdRan == true)
}
@Test
func `Conversation append generated messages preserves concurrent appends`() {
let conversation = Conversation()
conversation.addUserMessage("original")
let anchorID = conversation.messages[0].id
conversation.addUserMessage("concurrent")
conversation.appendGeneratedMessages([.assistant("generated")], afterMessageID: anchorID)
#expect(conversation.messages.map(\.content) == ["original", "generated", "concurrent"])
}
@Test
func `Conversation continue persists generated message from empty history`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStaticProvider(response: ProviderResponse(text: "hello", finishReason: .stop))
}
let conversation = Conversation(configuration: config)
let text = try await conversation.continueConversation(using: .anthropic(.opus48))
#expect(text == "hello")
#expect(conversation.messages.map(\.content) == ["hello"])
}
@Test
func `Conversation continue rolls back refused trailing user turn`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStaticProvider(response: ProviderResponse(text: "Refused by policy", finishReason: .contentFilter))
}
let conversation = Conversation(configuration: config)
conversation.addUserMessage("blocked")
let text = try await conversation.continueConversation(using: .anthropic(.fable5))
#expect(text.isEmpty)
#expect(conversation.messages.isEmpty)
}
@Test
func `Conversation continue preserves completed tool history after late refusal`() async throws {
let provider = MinimalSequenceProvider(responses: [
ProviderResponse(
text: "",
finishReason: .toolCalls,
toolCalls: [AgentToolCall(id: "call-1", name: "side_effect", arguments: [:])],
),
ProviderResponse(text: "Refused by policy", finishReason: .contentFilter),
])
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in provider }
let conversation = Conversation(configuration: config)
conversation.addUserMessage("do it")
let text = try await conversation.continueConversation(
using: .anthropic(.fable5),
tools: [sideEffectTool],
maxSteps: 2,
)
#expect(text.isEmpty)
let messages = conversation.getModelMessages()
#expect(messages.map(\.role) == [.user, .assistant, .tool])
#expect(messages[0].content == [.text("do it")])
#expect(messages[1].content.contains { part in
if case let .toolCall(toolCall) = part {
return toolCall.id == "call-1"
}
return false
})
#expect(messages[2].content.contains { part in
if case let .toolResult(toolResult) = part {
return toolResult.toolCallId == "call-1"
}
return false
})
}
@Test
func `Agent stream rejects non-streaming model before mutating conversation`() async throws {
let agent = Agent(
name: "test",
instructions: "test",
model: .anthropic(.fable5),
context: (),
)
await #expect(throws: TachikomaError.self) {
_ = try await agent.stream("hi")
}
#expect(agent.conversation.messages.map(\.content) == ["test"])
}
@Test
func `Conversation streaming rolls back refused trailing user turn`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("partial"),
.done(finishReason: .contentFilter),
])
}
let conversation = Conversation(configuration: config)
conversation.addUserMessage("blocked")
let stream = try await conversation.continueConversationStreaming(using: .openai(.gpt55))
var received = ""
for try await chunk in stream {
received += chunk
}
#expect(received == "partial")
#expect(conversation.messages.isEmpty)
}
@Test
func `Conversation streaming flushes buffered text on natural completion`() async throws {
let provider = MinimalStreamingProvider(deltas: [
.text("ok"),
])
let conversation = Conversation(configuration: TachikomaConfiguration(loadFromEnvironment: false))
let stream = try await conversation.continueConversationStreaming(using: .custom(provider: provider))
var received = ""
for try await chunk in stream {
received += chunk
}
#expect(received == "ok")
#expect(conversation.messages.map(\.content) == ["ok"])
}
@Test
func `Conversation streaming flushes buffered compatible text when done has no finish reason`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("ok"),
.done(),
])
}
let conversation = Conversation(configuration: config)
let stream = try await conversation.continueConversationStreaming(
using: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
)
var received = ""
for try await chunk in stream {
received += chunk
}
#expect(received == "ok")
#expect(conversation.messages.map(\.content) == ["ok"])
}
@Test
func `Conversation streaming flushes compatible text when stream ends without done`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("partial"),
])
}
let conversation = Conversation(configuration: config)
let stream = try await conversation.continueConversationStreaming(
using: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
)
var received = ""
for try await chunk in stream {
received += chunk
}
#expect(received == "partial")
#expect(conversation.messages.map(\.content) == ["partial"])
}
}
@Suite(.serialized)
private struct AgentRefusalTests {
@Test
func `Agent execute rolls back refused user turn`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStaticProvider(response: ProviderResponse(text: "Refused by policy", finishReason: .contentFilter))
}
let agent = Agent(
name: "test",
instructions: "test",
model: .anthropic(.fable5),
configuration: config,
context: (),
)
let response = try await agent.execute("blocked")
#expect(response.text.isEmpty)
#expect(response.finishReason == .contentFilter)
#expect(agent.conversation.messages.map(\.content) == ["test"])
}
@Test
func `Agent stream stays incremental by default when terminal content filter arrives`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("partial"),
.done(finishReason: .contentFilter),
])
}
let agent = Agent(
name: "test",
instructions: "test",
model: .openai(.gpt55),
configuration: config,
context: (),
)
let stream = try await agent.stream("blocked")
var received: [TextStreamDelta] = []
for try await delta in stream {
received.append(delta)
}
#expect(received.contains { $0.type == .textDelta && $0.content == "partial" })
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
#expect(agent.conversation.messages.map(\.content) == ["test"])
}
@Test
func `Agent stream explicit terminal buffering errors when stream ends without done`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("partial"),
])
}
let agent = Agent(
name: "test",
instructions: "test",
model: .openaiCompatible(modelId: "gpt-compatible", baseURL: "https://example.test"),
settings: GenerationSettings(streamBuffering: .untilTerminal),
configuration: config,
context: (),
)
let stream = try await agent.stream("hi")
do {
for try await _ in stream {}
Issue.record("Expected missing terminal status error")
} catch let error as TachikomaError {
guard case let .apiError(message) = error else {
Issue.record("Expected apiError, got \(error)")
return
}
#expect(message.contains("completion status"))
}
#expect(!agent.conversation.messages.map(\.content).contains("partial"))
}
@Test
func `Agent stream explicit terminal buffering suppresses Azure OpenAI refusals`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("partial"),
.done(finishReason: .contentFilter),
])
}
let agent = Agent(
name: "test",
instructions: "test",
model: .azureOpenAI(deployment: "gpt-compatible", endpoint: "https://example.openai.azure.com"),
settings: GenerationSettings(streamBuffering: .untilTerminal),
configuration: config,
context: (),
)
let stream = try await agent.stream("blocked")
var received: [TextStreamDelta] = []
for try await delta in stream {
received.append(delta)
}
#expect(!received.contains { $0.type == .textDelta && $0.content == "partial" })
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
#expect(agent.conversation.messages.map(\.content) == ["test"])
}
@Test
func `Agent stream explicit terminal buffering suppresses Google refusals`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(deltas: [
.text("partial"),
.done(finishReason: .contentFilter),
])
}
let agent = Agent(
name: "test",
instructions: "test",
model: .google(.gemini25Flash),
settings: GenerationSettings(streamBuffering: .untilTerminal),
configuration: config,
context: (),
)
let stream = try await agent.stream("blocked")
var received: [TextStreamDelta] = []
for try await delta in stream {
received.append(delta)
}
#expect(!received.contains { $0.type == .textDelta && $0.content == "partial" })
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
#expect(agent.conversation.messages.map(\.content) == ["test"])
}
@Test
func `Agent stream explicit terminal buffering suppresses registered custom OpenAI refusals`() async throws {
try await self.withRegisteredCustomProvider(
"""
{
"customProviders": {
"proxy": {
"type": "openai",
"options": { "baseURL": "https://example.test/v1" }
}
}
}
""",
) {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
MinimalStreamingProvider(
modelId: "proxy/gpt-compatible",
deltas: [
.text("partial"),
.done(finishReason: .contentFilter),
],
)
}
let agent = Agent(
name: "test",
instructions: "test",
model: .custom(provider: MinimalStreamingProvider(modelId: "proxy/gpt-compatible", deltas: [])),
settings: GenerationSettings(streamBuffering: .untilTerminal),
configuration: config,
context: (),
)
let stream = try await agent.stream("blocked")
var received: [TextStreamDelta] = []
for try await delta in stream {
received.append(delta)
}
#expect(!received.contains { $0.type == .textDelta && $0.content == "partial" })
#expect(received.contains { $0.type == .done && $0.finishReason == .contentFilter })
#expect(agent.conversation.messages.map(\.content) == ["test"])
}
}
@Test
func `Agent stream releases continuation gate when consumer stops early`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
StallingStreamingProvider()
}
let agent = Agent(
name: "test",
instructions: "test",
model: .custom(provider: StallingStreamingProvider()),
configuration: config,
context: (),
)
do {
let stream = try await agent.stream("first")
var iterator = stream.makeAsyncIterator()
let firstDelta = try await iterator.next()
#expect(firstDelta?.type == .textDelta)
#expect(firstDelta?.content == "partial")
}
try await Task.sleep(nanoseconds: 10_000_000)
let response = try await withTimeout(0.2) {
try await agent.execute("second")
}
#expect(response.text == "after")
}
@Test
func `Agent execute preserves completed tool history after late refusal`() async throws {
let provider = MinimalSequenceProvider(responses: [
ProviderResponse(
text: "",
finishReason: .toolCalls,
toolCalls: [AgentToolCall(id: "call-1", name: "side_effect", arguments: [:])],
),
ProviderResponse(text: "Refused by policy", finishReason: .contentFilter),
])
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in provider }
let agent = Agent(
name: "test",
instructions: "test",
model: .anthropic(.fable5),
tools: [sideEffectTool],
configuration: config,
context: (),
)
let response = try await agent.execute("do it")
#expect(response.text.isEmpty)
#expect(response.finishReason == .contentFilter)
let messages = agent.conversation.getModelMessages()
#expect(messages.map(\.role) == [.system, .user, .assistant, .tool])
#expect(messages[1].content == [.text("do it")])
#expect(messages[2].content.contains { part in
if case let .toolCall(toolCall) = part {
return toolCall.id == "call-1"
}
return false
})
#expect(messages[3].content.contains { part in
if case let .toolResult(toolResult) = part {
return toolResult.toolCallId == "call-1"
}
return false
})
}
private func withRegisteredCustomProvider(
_ configJSON: String,
operation: () async throws -> Void,
) async throws {
let originalProfile = TachikomaConfiguration.profileDirectoryName
let tempProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
let emptyProfile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempProfile, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: emptyProfile, withIntermediateDirectories: true)
try configJSON.write(to: tempProfile.appendingPathComponent("config.json"), atomically: true, encoding: .utf8)
try #"{"customProviders":{}}"#.write(
to: emptyProfile.appendingPathComponent("config.json"),
atomically: true,
encoding: .utf8,
)
TachikomaConfiguration.profileDirectoryName = tempProfile.path
CustomProviderRegistry.shared.loadFromProfile()
do {
try await operation()
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
CustomProviderRegistry.shared.loadFromProfile()
TachikomaConfiguration.profileDirectoryName = originalProfile
} catch {
TachikomaConfiguration.profileDirectoryName = emptyProfile.path
CustomProviderRegistry.shared.loadFromProfile()
TachikomaConfiguration.profileDirectoryName = originalProfile
throw error
}
}
}
private struct StallingStreamingProvider: ModelProvider {
let modelId = "stalling-streaming"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities(supportsStreaming: true)
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "after")
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
AsyncThrowingStream { continuation in
continuation.yield(.text("partial"))
}
}
}
private actor ConversationLockProbe {
var secondRan = false
var thirdRan = false
private var firstStarted = false
private var firstStartedWaiters: [CheckedContinuation<Void, Never>] = []
private var releaseWaiters: [CheckedContinuation<Void, Never>] = []
func markFirstStarted() {
self.firstStarted = true
let waiters = self.firstStartedWaiters
self.firstStartedWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
func waitUntilFirstStarted() async {
if self.firstStarted {
return
}
await withCheckedContinuation { continuation in
self.firstStartedWaiters.append(continuation)
}
}
func waitForRelease() async {
await withCheckedContinuation { continuation in
self.releaseWaiters.append(continuation)
}
}
func releaseFirst() {
let waiters = self.releaseWaiters
self.releaseWaiters.removeAll()
for waiter in waiters {
waiter.resume()
}
}
func markSecondRan() {
self.secondRan = true
}
func markThirdRan() {
self.thirdRan = true
}
}
private final class MinimalModelBox: @unchecked Sendable {
private let lock = NSLock()
private var _model: LanguageModel?
var model: LanguageModel? {
get {
self.lock.lock()
defer { self.lock.unlock() }
return self._model
}
set {
self.lock.lock()
self._model = newValue
self.lock.unlock()
}
}
}
private struct MinimalStaticProvider: ModelProvider {
let modelId = "minimal-static"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
let response: ProviderResponse
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
self.response
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
AsyncThrowingStream { continuation in
continuation.finish()
}
}
}
private struct MinimalStreamingProvider: ModelProvider {
let modelId: String
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities(supportsStreaming: true)
let deltas: [TextStreamDelta]
init(modelId: String = "minimal-streaming", deltas: [TextStreamDelta]) {
self.modelId = modelId
self.deltas = deltas
}
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
ProviderResponse(text: "")
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, any Error> {
AsyncThrowingStream { continuation in
for delta in self.deltas {
continuation.yield(delta)
}
continuation.finish()
}
}
}
private struct MinimalSequenceProvider: ModelProvider {
let modelId = "minimal-sequence"
let baseURL: String? = nil
let apiKey: String? = nil
let capabilities = ModelCapabilities()
private let queue: MinimalResponseQueue
init(responses: [ProviderResponse]) {
self.queue = MinimalResponseQueue(responses: responses)
}
func generateText(request _: ProviderRequest) async throws -> ProviderResponse {
self.queue.next()
}
func streamText(request _: ProviderRequest) async throws -> AsyncThrowingStream<TextStreamDelta, Error> {
AsyncThrowingStream { continuation in
continuation.finish()
}
}
}
private final class MinimalResponseQueue: @unchecked Sendable {
private let lock = NSLock()
private var responses: [ProviderResponse]
init(responses: [ProviderResponse]) {
self.responses = responses
}
func next() -> ProviderResponse {
self.lock.lock()
defer { self.lock.unlock() }
if self.responses.count > 1 {
return self.responses.removeFirst()
}
return self.responses[0]
}
}
private let sideEffectTool = Tachikoma.createTool(
name: "side_effect",
description: "Records an external action",
parameters: [],
required: [],
) { _ in
AnyAgentToolValue(string: "done")
}

View File

@ -7,8 +7,11 @@ enum ModelCapabilitiesTests {
@Test
func `GPT-5 models exclude temperature and topP`() {
let models: [LanguageModel] = [
.openai(.gpt52),
.openai(.gpt51),
.openai(.gpt5ChatLatest),
.openai(.gpt55),
.openai(.gpt54),
.openai(.gpt54Mini),
.openai(.gpt54Nano),
.openai(.gpt5),
.openai(.gpt5Mini),
.openai(.gpt5Nano),
@ -27,34 +30,24 @@ enum ModelCapabilitiesTests {
}
@Test
func `Gemini 3 Flash supports thinking config options`() {
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: .google(.gemini3Flash))
func `chat-latest does not advertise audio input`() {
#expect(LanguageModel.openai(.chatLatest).supportsVision)
#expect(LanguageModel.openai(.chatLatest).supportsTools)
#expect(LanguageModel.openai(.chatLatest).supportsAudioInput == false)
}
@Test
func `Gemini 3_5 Flash supports thinking config options`() {
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: .google(.gemini35Flash))
#expect(capabilities.supportsTopK)
#expect(capabilities.supportedProviderOptions.supportsThinkingConfig)
#expect(capabilities.supportedProviderOptions.supportsSafetySettings)
}
@Test
func `Reasoning models have forced temperature`() {
let model = LanguageModel.openai(.o4Mini)
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: model)
#expect(!capabilities.supportsTemperature)
#expect(!capabilities.supportsTopP)
#expect(capabilities.forcedTemperature == 1.0)
#expect(capabilities.excludedParameters.contains("temperature"))
#expect(capabilities.excludedParameters.contains("topP"))
#expect(capabilities.supportedProviderOptions.supportsReasoningEffort)
#expect(capabilities.supportedProviderOptions.supportsPreviousResponseId)
}
@Test
func `GPT-4 models support standard parameters`() {
func `Custom OpenAI models support standard parameters`() {
let models: [LanguageModel] = [
.openai(.gpt4o),
.openai(.gpt4oMini),
.openai(.gpt41),
.openai(.gpt4Turbo),
.openai(.custom("custom-openai")),
]
for model in models {
@ -65,17 +58,16 @@ enum ModelCapabilitiesTests {
#expect(capabilities.supportsMaxTokens)
#expect(capabilities.supportsFrequencyPenalty)
#expect(capabilities.supportsPresencePenalty)
#expect(capabilities.supportedProviderOptions.supportsParallelToolCalls)
#expect(capabilities.supportedProviderOptions.supportsResponseFormat)
#expect(capabilities.supportedProviderOptions.supportsLogprobs)
}
}
@Test
func `Claude models support thinking`() {
let models: [LanguageModel] = [
.anthropic(.fable5),
.anthropic(.opus47),
.anthropic(.opus4),
.anthropic(.sonnet4),
.anthropic(.sonnet46),
.anthropic(.sonnet45),
.anthropic(.haiku45),
]
@ -88,9 +80,37 @@ enum ModelCapabilitiesTests {
}
}
@Test
func `Claude Fable 5 and Opus 4_7 plus 4_8 advertise adaptive thinking without sampling options`() {
for model in [LanguageModel.anthropic(.fable5), .anthropic(.opus47), .anthropic(.opus48)] {
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: model)
#expect(!capabilities.supportsTemperature)
#expect(!capabilities.supportsTopP)
#expect(!capabilities.supportsTopK)
#expect(capabilities.excludedParameters.contains("temperature"))
#expect(capabilities.excludedParameters.contains("topP"))
#expect(capabilities.excludedParameters.contains("topK"))
#expect(capabilities.supportedProviderOptions.supportsThinking)
#expect(capabilities.supportedProviderOptions.supportsCacheControl)
}
}
@Test
func `Custom Anthropic models keep thinking options by default`() {
let capabilities = ModelCapabilityRegistry.shared
.capabilities(for: .anthropic(.custom("claude-opus-latest")))
#expect(capabilities.supportedProviderOptions.supportsThinking)
#expect(capabilities.supportedProviderOptions.supportsCacheControl)
}
@Test
func `Google models support topK and thinking`() {
let models: [LanguageModel] = [
.google(.gemini35Flash),
.google(.gemini31ProPreview),
.google(.gemini31FlashLite),
.google(.gemini25Pro),
.google(.gemini25Flash),
.google(.gemini25FlashLite),
@ -108,8 +128,9 @@ enum ModelCapabilitiesTests {
@Test
func `Mistral models support safe mode`() {
let models: [LanguageModel] = [
.mistral(.large2),
.mistral(.codestral),
.mistral(.largeLatest),
.mistral(.medium35),
.mistral(.codestralLatest),
]
for model in models {
@ -122,8 +143,8 @@ enum ModelCapabilitiesTests {
@Test
func `Groq models support speed level`() {
let models: [LanguageModel] = [
.groq(.llama3170b),
.groq(.llama370b),
.groq(.llama3370b),
.groq(.llama4Maverick),
]
for model in models {
@ -136,8 +157,8 @@ enum ModelCapabilitiesTests {
@Test
func `Grok models support fun mode`() {
let models: [LanguageModel] = [
.grok(.grok4),
.grok(.grok3),
.grok(.grok43),
.grok(.grok420Reasoning),
]
for model in models {
@ -151,7 +172,7 @@ enum ModelCapabilitiesTests {
struct SettingsValidationTests {
@Test
func `Validate settings for GPT-5.1`() {
func `Validate settings for GPT-5.5`() {
let settings = GenerationSettings(
maxTokens: 1000,
temperature: 0.7,
@ -166,7 +187,7 @@ enum ModelCapabilitiesTests {
),
)
let validated = settings.validated(for: .openai(.gpt51))
let validated = settings.validated(for: .openai(.gpt55))
#expect(validated.maxTokens == 1000)
#expect(validated.temperature == nil) // Excluded
@ -178,7 +199,20 @@ enum ModelCapabilitiesTests {
}
@Test
func `Validate settings for O3 with forced temperature`() {
func `Validate settings preserves stream buffering mode`() {
let settings = GenerationSettings(
temperature: 0.7,
streamBuffering: .untilTerminal,
)
let validated = settings.validated(for: .openai(.gpt55))
#expect(validated.temperature == nil)
#expect(validated.streamBuffering == .untilTerminal)
}
@Test
func `Validate settings for GPT-5 strips unsupported options`() {
let settings = GenerationSettings(
temperature: 0.5,
topP: 0.8,
@ -190,16 +224,16 @@ enum ModelCapabilitiesTests {
),
)
let validated = settings.validated(for: LanguageModel.openai(.o4Mini))
let validated = settings.validated(for: LanguageModel.openai(.gpt55))
#expect(validated.temperature == 1.0) // Forced to 1.0
#expect(validated.temperature == nil) // Excluded
#expect(validated.topP == nil) // Excluded
#expect(validated.providerOptions.openai?.reasoningEffort == .high) // Kept
#expect(validated.providerOptions.openai?.verbosity == nil) // Removed
#expect(validated.providerOptions.openai?.reasoningEffort == nil) // Removed
#expect(validated.providerOptions.openai?.verbosity == .medium) // Kept
}
@Test
func `Validate settings for GPT-4`() {
func `Validate settings for custom OpenAI model`() {
let settings = GenerationSettings(
maxTokens: 2000,
temperature: 0.8,
@ -216,17 +250,13 @@ enum ModelCapabilitiesTests {
),
)
let validated = settings.validated(for: .openai(.gpt4o))
let validated = settings.validated(for: .openai(.custom("custom-openai")))
#expect(validated.maxTokens == 2000)
#expect(validated.temperature == 0.8)
#expect(validated.topP == 0.95)
#expect(validated.frequencyPenalty == 0.2)
#expect(validated.presencePenalty == 0.1)
#expect(validated.providerOptions.openai?.parallelToolCalls == true)
#expect(validated.providerOptions.openai?.responseFormat == .json)
#expect(validated.providerOptions.openai?.logprobs == true)
#expect(validated.providerOptions.openai?.topLogprobs == 3)
}
@Test
@ -252,6 +282,72 @@ enum ModelCapabilitiesTests {
// OpenAI options remain unfiltered (they won't be used by Anthropic provider)
#expect(validated.providerOptions.openai?.verbosity == .high)
}
@Test
func `Validate Anthropic options keeps adaptive thinking for Opus 4_8`() {
let settings = GenerationSettings(
temperature: 0.7,
topP: 0.9,
topK: 40,
providerOptions: .init(
anthropic: .init(
thinking: .enabled(budgetTokens: 3000),
cacheControl: .persistent,
),
),
)
let validated = settings.validated(for: LanguageModel.anthropic(.opus48))
#expect(validated.temperature == nil)
#expect(validated.topP == nil)
#expect(validated.topK == nil)
#expect(validated.providerOptions.anthropic?.thinking != nil)
#expect(validated.providerOptions.anthropic?.cacheControl == .persistent)
}
@Test
func `Validate Anthropic-compatible Fable strips unsupported sampling`() {
let settings = GenerationSettings(
temperature: 0.7,
topP: 0.9,
topK: 40,
providerOptions: .init(
anthropic: .init(thinking: .adaptive),
),
)
let validated = settings.validated(for: LanguageModel.anthropicCompatible(
modelId: "claude-fable-5",
baseURL: "https://example.test",
))
#expect(validated.temperature == nil)
#expect(validated.topP == nil)
#expect(validated.topK == nil)
#expect(validated.providerOptions.anthropic?.thinking != nil)
}
@Test
func `Validate direct custom Fable strips unsupported sampling`() {
let settings = GenerationSettings(
temperature: 0.7,
topP: 0.9,
topK: 40,
providerOptions: .init(
anthropic: .init(thinking: .adaptive),
),
)
let validated = settings.validated(
for: LanguageModel.anthropic(.custom("anthropic.claude-fable-5")),
)
#expect(validated.temperature == nil)
#expect(validated.topP == nil)
#expect(validated.topK == nil)
#expect(validated.providerOptions.anthropic?.thinking != nil)
}
}
struct CustomModelTests {
@ -307,8 +403,8 @@ enum ModelCapabilitiesTests {
@Test
func `Concurrent capability access`() async {
let models: [LanguageModel] = [
.openai(.gpt51),
.openai(.gpt4o),
.openai(.gpt54),
.openai(.gpt55),
.anthropic(.opus4),
.google(.gemini25Flash),
]

View File

@ -9,21 +9,53 @@ struct ModelParsingTests {
}
@Test
func `parse GPT-5.1 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.1")
#expect(parsed == .openai(.gpt51))
func `parse GPT-5.5 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.5")
#expect(parsed == .openai(.gpt55))
}
@Test
func `parse GPT-5.2 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.2")
#expect(parsed == .openai(.gpt52))
func `parse chat latest OpenAI alias`() throws {
#expect(LanguageModel.parse(from: "chat-latest") == .openai(.chatLatest))
#expect(LanguageModel.parse(from: "gpt-5-chat-latest") == .openai(.gpt5ChatLatest))
#expect(LanguageModel.parse(from: "openai/chat-latest") == .openai(.chatLatest))
#expect(LanguageModel.parse(from: "openai/gpt-5-chat-latest") == .openai(.gpt5ChatLatest))
#expect(try ModelSelector.parseModel("openai/chat-latest") == .openai(.chatLatest))
#expect(try ModelSelector.parseModel("openai/gpt-5-chat-latest") == .openai(.gpt5ChatLatest))
}
@Test
func `parse GPT-5.1 nano alias`() {
let parsed = LanguageModel.parse(from: "gpt51-nano")
#expect(parsed == .openai(.gpt5Nano))
func `parse GPT-5.4 base model`() {
let parsed = LanguageModel.parse(from: "gpt-5.4")
#expect(parsed == .openai(.gpt54))
}
@Test
func `parse GPT-5.4 nano alias`() {
let parsed = LanguageModel.parse(from: "gpt54-nano")
#expect(parsed == .openai(.gpt54Nano))
}
@Test
func `LanguageModel rejects retired OpenAI ids`() {
for model in ["gpt-4o", "gpt-4.1", "gpt-5.1", "gpt-5.2", "gpt-5-thinking"] {
#expect(LanguageModel.parse(from: model) == nil)
}
}
@Test
func `parse Claude Fable 5 model id`() throws {
#expect(LanguageModel.parse(from: "claude-fable-5") == .anthropic(.fable5))
#expect(LanguageModel.parse(from: "fable") == .anthropic(.fable5))
#expect(try ModelSelector.parseModel("fable5") == .anthropic(.fable5))
#expect(LanguageModel.parse(from: "my-fable5-7b") == nil)
}
@Test
func `parse Claude Opus 4.8 model id`() {
let parsed = LanguageModel.parse(from: "claude-opus-4-8")
#expect(parsed == .anthropic(.opus48))
#expect(LanguageModel.parse(from: "my-opus48-distill") == nil)
}
@Test
@ -33,20 +65,172 @@ struct ModelParsingTests {
}
@Test
func `parse shorthand Claude alias`() {
func `parse shorthand Claude alias`() throws {
let parsed = LanguageModel.parse(from: "claude")
#expect(parsed == .anthropic(.sonnet45))
#expect(parsed == .anthropic(.opus48))
#expect(try ModelSelector.parseModel("anthropic") == .anthropic(.opus48))
}
@Test
func `parse Gemini 3 Flash model id`() {
let parsed = LanguageModel.parse(from: "gemini-3-flash")
#expect(parsed == .google(.gemini3Flash))
func `parse Gemini 3.5 Flash model id`() {
let parsed = LanguageModel.parse(from: "gemini-3.5-flash")
#expect(parsed == .google(.gemini35Flash))
}
@Test
func `parse shorthand Gemini alias`() {
let parsed = LanguageModel.parse(from: "gemini")
#expect(parsed == .google(.gemini3Flash))
#expect(parsed == .google(.gemini35Flash))
}
@Test
func `parse provider qualified latest hosted models`() throws {
#expect(LanguageModel.parse(from: "anthropic/claude-fable-5") == .anthropic(.fable5))
#expect(LanguageModel.parse(from: "anthropic/claude-opus-4-8") == .anthropic(.opus48))
#expect(LanguageModel.parse(from: "google/gemini-3.5-flash") == .google(.gemini35Flash))
#expect(LanguageModel.parse(from: "xai/grok-4.3-latest") == .grok(.grok43))
#expect(LanguageModel.parse(from: "grok-4-latest") == .grok(.grok43))
#expect(LanguageModel.parse(from: "grok-4") == .grok(.grok43))
#expect(LanguageModel.parse(from: "xai/grok-code-fast-1") == .grok(.custom("grok-code-fast-1")))
#expect(try ModelSelector.parseModel("grok-4") == .grok(.grok43))
}
@Test
func `parse rejects provider-qualified hosted model mismatches`() {
#expect(LanguageModel.parse(from: "openai/claude") == nil)
#expect(LanguageModel.parse(from: "google/claude") == nil)
#expect(LanguageModel.parse(from: "xai/gemini-3.5-flash") == nil)
#expect(LanguageModel.parse(from: "anthropic/gpt-5.5") == nil)
}
@Test
func `ModelSelector keeps generic slash IDs as OpenRouter models`() throws {
#expect(try ModelSelector
.parseModel("anthropic/claude-opus-4-8") == .openRouter(modelId: "anthropic/claude-opus-4-8"))
#expect(try ModelSelector
.parseModel("google/gemini-3.5-flash") == .openRouter(modelId: "google/gemini-3.5-flash"))
#expect(try ModelSelector.parseModel("xai/grok-4.3-latest") == .grok(.grok43))
#expect(try ModelSelector.parseModel("openai/claude") == .openRouter(modelId: "openai/claude"))
}
@Test
func `parse MiniMax model ids`() throws {
#expect(LanguageModel.parse(from: "MiniMax-M2.7") == .minimax(.m27))
#expect(LanguageModel.parse(from: "minimax/m2.7") == .minimax(.m27))
#expect(try ModelSelector.parseModel("minimax/m2-7") == .minimax(.m27))
#expect(LanguageModel.parse(from: "minimax/MiniMax-M2.7-highspeed") == .minimax(.m27Highspeed))
#expect(LanguageModel.parse(from: "minimax/m2.7-highspeed") == .minimax(.m27Highspeed))
#expect(try ModelSelector.parseModel("minimax/m2-7-highspeed") == .minimax(.m27Highspeed))
#expect(LanguageModel.parse(from: "minimax") == .minimax(.m27))
#expect(LanguageModel.parse(from: "minimax-cn/MiniMax-M2.7") == .minimaxCN(.m27))
#expect(LanguageModel.parse(from: "minimax-cn/m2.7-highspeed") == .minimaxCN(.m27Highspeed))
#expect(try ModelSelector.parseModel("minimax-cn/m2-7") == .minimaxCN(.m27))
#expect(try ModelSelector.parseModel("minimax_cn/m2.7") == .minimaxCN(.m27))
#expect(LanguageModel.parse(from: "minimaxi/m2.7") == .minimaxCN(.m27))
#expect(LanguageModel.parse(from: "minimax-cn") == .minimaxCN(.m27))
}
@Test
func `parse OpenRouter model ids`() throws {
#expect(LanguageModel
.parse(from: "openrouter/xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
#expect(LanguageModel.parse(from: "xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
#expect(try ModelSelector.parseModel("xiaomi/mimo-v2.5-pro") == .openRouter(modelId: "xiaomi/mimo-v2.5-pro"))
}
@Test
func `parse custom Ollama Qwen vision model without falling back to Llama`() {
let parsed = LanguageModel.parse(from: "qwen2.5vl:3b")
#expect(parsed == .ollama(.custom("qwen2.5vl:3b")))
#expect(parsed?.modelId == "qwen2.5vl:3b")
#expect(parsed?.supportsVision == true)
#expect(parsed?.supportsTools == false)
}
@Test
func `parse provider-qualified custom Ollama model`() {
let parsed = LanguageModel.parse(from: "ollama/qwen2.5vl:3b")
#expect(parsed == .ollama(.custom("qwen2.5vl:3b")))
#expect(parsed?.modelId == "qwen2.5vl:3b")
}
@Test
func `parse local provider shortcuts`() {
#expect(LanguageModel.parse(from: "ollama") == .ollama(.llama33))
#expect(LanguageModel.parse(from: "lmstudio") == .lmstudio(.gptOSS120B))
#expect(LanguageModel.parse(from: "lmstudio/openai/gpt-oss-120b") == .lmstudio(.gptOSS120B))
#expect(LanguageModel.parse(from: "lmstudio/custom-local-model") == .lmstudio(.custom("custom-local-model")))
}
@Test
func `ModelSelector parses local provider selections`() throws {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
#expect(try ModelSelector.parseModel("lmstudio") == .lmstudio(.gptOSS120B))
#expect(try ModelSelector.parseModel("lmstudio/openai/gpt-oss-120b") == .lmstudio(.gptOSS120B))
#expect(try ModelSelector.parseModel("lm-studio/custom-local") == .lmstudio(.custom("custom-local")))
#expect(ModelSelector.availableModels(for: "lmstudio").contains("openai/gpt-oss-120b"))
}
}
@Test
func `ProviderParser keeps configured Google model behavior`() {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
let model = ProviderParser.determineDefaultModel(
from: "google/gemini-3.1-pro-preview",
hasOpenAI: false,
hasAnthropic: false,
)
#expect(model == .google(.gemini31ProPreview))
}
}
@Test
func `ProviderParser keeps keyless fallback local by default`() {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
let model = ProviderParser.determineDefaultModel(
from: "",
hasOpenAI: false,
hasAnthropic: false,
)
#expect(model == .ollama(.llama33))
}
}
@Test
func `ProviderParser accepts MiniMax China provider aliases`() {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
for provider in ["minimax-cn", "minimax_cn", "minimaxi"] {
let model = ProviderParser.determineDefaultModel(
from: "\(provider)/m2.7",
hasOpenAI: false,
hasAnthropic: false,
hasMiniMax: true,
)
#expect(model == .minimaxCN(.m27))
}
}
}
@Test
func `ModelSelector rejects legacy OpenAI before Ollama fallback`() throws {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
for model in ["gpt-4o", "gpt-4.1", "gpt-3.5-turbo", "o4-mini", "o3-mini", "gpt-5.2"] {
#expect(throws: ModelValidationError.self) {
_ = try ModelSelector.parseModel(model)
}
}
}
}
@Test
func `ModelSelector rejects Claude 3 before Ollama fallback`() throws {
if #available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) {
#expect(throws: ModelValidationError.self) {
_ = try ModelSelector.parseModel("claude-3-sonnet")
}
}
}
}

View File

@ -121,6 +121,337 @@ struct OpenAICompatibleHelperTests {
#expect(deltas == "Hello world")
}
@Test
func `streamText maps content filter finish reasons`() async throws {
let request = ProviderRequest(
messages: [ModelMessage(role: .user, content: [.text("blocked")])],
)
let deltas = try await withMockedSession { urlRequest in
let sse = """
data: {\"id\":\"chunk_1\",\"choices\":[{\"delta\":{\"content\":\"partial\"},\"index\":0,\"finish_reason\":null}]}
data: {\"id\":\"chunk_2\",\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"content_filter\"}]}
data: [DONE]
""".utf8Data()
let response = HTTPURLResponse(
url: urlRequest.url!,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "text/event-stream"],
)!
return (response, sse)
} operation: { session in
let stream = try await OpenAICompatibleHelper.streamText(
request: request,
modelId: "compatible-model",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "TestProvider",
session: session,
)
var deltas: [TextStreamDelta] = []
for try await delta in stream {
deltas.append(delta)
}
return deltas
}
#expect(deltas.contains { $0.type == .textDelta && $0.content == "partial" })
#expect(deltas.contains { $0.type == .done && $0.finishReason == .contentFilter })
}
@Test
func `OpenAI-compatible provider forwards configured headers`() async throws {
let request = ProviderRequest(
messages: [ModelMessage(role: .user, content: [.text("ping")])],
)
try await self.withMockedSession { urlRequest in
#expect(urlRequest.value(forHTTPHeaderField: "client_id") == "proxy-client")
#expect(urlRequest.value(forHTTPHeaderField: "client_secret") == "proxy-secret")
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "pong"))
} operation: { session in
let configuration = TachikomaConfiguration(apiKeys: ["openai_compatible": "sk-test"])
let provider = try OpenAICompatibleProvider(
modelId: "compatible-model",
baseURL: "https://mock.compatible",
configuration: configuration,
additionalHeaders: [
"client_id": "proxy-client",
"client_secret": "proxy-secret",
],
session: session,
)
let response = try await provider.generateText(request: request)
#expect(response.text == "pong")
}
}
@Test
func `generateText decodes OpenRouter reasoning details`() async throws {
let response = try await withMockedSession { urlRequest in
let reasoningDetails: [[String: String]] = [["type": "reasoning.encrypted", "data": "sealed"]]
let toolCall: [String: Any] = [
"id": "call-1",
"type": "function",
"function": ["name": "lookup", "arguments": "{}"],
]
let toolCalls = [toolCall]
let choice: [String: Any] = [
"index": 0,
"message": [
"role": "assistant",
"content": NSNull(),
"reasoning_details": reasoningDetails,
"tool_calls": toolCalls,
],
"finish_reason": "tool_calls",
]
let payload: [String: Any] = [
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1_700_000_000,
"model": "anthropic/claude-fable-5",
"choices": [choice],
]
return try self.jsonResponse(for: urlRequest, data: JSONSerialization.data(withJSONObject: payload))
} operation: { session in
try await OpenAICompatibleHelper.generateText(
request: ProviderRequest(messages: [.user("hi")]),
modelId: "anthropic/claude-fable-5",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "OpenRouter",
session: session,
)
}
let reasoning = try #require(response.reasoning.first)
#expect(reasoning.type == "openrouter_reasoning_details")
#expect(reasoning.rawJSON?.contains("reasoning.encrypted") == true)
#expect(response.toolCalls?.first?.id == "call-1")
}
@Test
func `generateText strips unsupported Fable sampling for OpenRouter route`() async throws {
let capture = CapturedRequest()
let request = ProviderRequest(
messages: [ModelMessage(role: .user, content: [.text("ping")])],
settings: GenerationSettings(maxTokens: 128, temperature: 0.7),
)
_ = try await self.withMockedSession { urlRequest in
capture.body = self.bodyData(from: urlRequest)
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "pong"))
} operation: { session in
try await OpenAICompatibleHelper.generateText(
request: request,
modelId: "anthropic/claude-fable-5",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "OpenRouter",
session: session,
)
}
let bodyJSON = try #require(capture.body).jsonObject()
#expect(bodyJSON["temperature"] == nil)
#expect(bodyJSON["max_tokens"] as? Int == 128)
}
@Test
func `generateText replays OpenRouter reasoning details on assistant tool messages`() async throws {
let capture = CapturedRequest()
let rawReasoning = #"[{"type":"reasoning.encrypted","data":"sealed"}]"#
let call = AgentToolCall(id: "call-1", name: "lookup", arguments: [:])
let request = try ProviderRequest(messages: [
.user("hi"),
ModelMessage(
role: .assistant,
content: [.text("")],
channel: .thinking,
metadata: .init(customData: [
"openrouter.reasoning_details": rawReasoning,
"tachikoma.reasoning.provider": "openrouter",
"tachikoma.reasoning.model": "anthropic/claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://mock.compatible")),
]),
),
ModelMessage(role: .assistant, content: [.toolCall(call)]),
ModelMessage(
role: .tool,
content: [.toolResult(.success(toolCallId: "call-1", result: AnyAgentToolValue(string: "ok")))],
),
])
_ = try await self.withMockedSession { urlRequest in
capture.body = self.bodyData(from: urlRequest)
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
} operation: { session in
try await OpenAICompatibleHelper.generateText(
request: request,
modelId: "anthropic/claude-fable-5",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "OpenRouter",
session: session,
)
}
let bodyJSON = try #require(capture.body).jsonObject()
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
let assistant = try #require(messages.first { $0["role"] as? String == "assistant" })
let details = try #require(assistant["reasoning_details"] as? [[String: Any]])
#expect(details.first?["type"] as? String == "reasoning.encrypted")
#expect(details.first?["data"] as? String == "sealed")
#expect(assistant["tool_calls"] != nil)
}
@Test
func `generateText replays OpenRouter reasoning details on reasoning-only assistant boundary`() async throws {
let capture = CapturedRequest()
let rawReasoning = #"[{"type":"reasoning.encrypted","data":"sealed"}]"#
let request = try ProviderRequest(messages: [
.user("first"),
ModelMessage(
role: .assistant,
content: [.text("")],
channel: .thinking,
metadata: .init(customData: [
"openrouter.reasoning_details": rawReasoning,
"tachikoma.reasoning.provider": "openrouter",
"tachikoma.reasoning.model": "anthropic/claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://mock.compatible")),
]),
),
ModelMessage(
role: .assistant,
content: [.text("")],
metadata: .init(customData: ["tachikoma.internal.boundary": "reasoning_only"]),
),
.user("next"),
])
_ = try await self.withMockedSession { urlRequest in
capture.body = self.bodyData(from: urlRequest)
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
} operation: { session in
try await OpenAICompatibleHelper.generateText(
request: request,
modelId: "anthropic/claude-fable-5",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "OpenRouter",
session: session,
)
}
let bodyJSON = try #require(capture.body).jsonObject()
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
let assistantIndex = try #require(messages.firstIndex { $0["role"] as? String == "assistant" })
let assistant = messages[assistantIndex]
let details = try #require(assistant["reasoning_details"] as? [[String: Any]])
#expect(details.first?["data"] as? String == "sealed")
let nextMessage = try #require(messages.indices
.contains(assistantIndex + 1) ? messages[assistantIndex + 1] : nil)
#expect(nextMessage["role"] as? String == "user")
}
@Test
func `generateText does not replay OpenRouter reasoning from another endpoint`() async throws {
let capture = CapturedRequest()
let rawReasoning = #"[{"type":"reasoning.encrypted","data":"sealed"}]"#
let call = AgentToolCall(id: "call-1", name: "lookup", arguments: [:])
let request = try ProviderRequest(messages: [
.user("hi"),
ModelMessage(
role: .assistant,
content: [.text("")],
channel: .thinking,
metadata: .init(customData: [
"openrouter.reasoning_details": rawReasoning,
"tachikoma.reasoning.provider": "openrouter",
"tachikoma.reasoning.model": "anthropic/claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://other.example.test")),
]),
),
ModelMessage(role: .assistant, content: [.toolCall(call)]),
ModelMessage(
role: .tool,
content: [.toolResult(.success(toolCallId: "call-1", result: AnyAgentToolValue(string: "ok")))],
),
])
_ = try await self.withMockedSession { urlRequest in
capture.body = self.bodyData(from: urlRequest)
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
} operation: { session in
try await OpenAICompatibleHelper.generateText(
request: request,
modelId: "anthropic/claude-fable-5",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "OpenRouter",
session: session,
)
}
let bodyJSON = try #require(capture.body).jsonObject()
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
let assistantMessages = messages.filter { $0["role"] as? String == "assistant" }
#expect(assistantMessages.allSatisfy { $0["reasoning_details"] == nil })
}
@Test
func `generateText drops unmatched OpenRouter reasoning instead of serializing it as text`() async throws {
let capture = CapturedRequest()
let request = try ProviderRequest(messages: [
.user("hi"),
ModelMessage(
role: .assistant,
content: [.text("private reasoning")],
channel: .thinking,
metadata: .init(customData: [
"openrouter.reasoning": "private reasoning",
"tachikoma.reasoning.provider": "openrouter",
"tachikoma.reasoning.model": "other-model",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://mock.compatible")),
]),
),
.assistant("visible"),
])
_ = try await self.withMockedSession { urlRequest in
capture.body = self.bodyData(from: urlRequest)
return self.jsonResponse(for: urlRequest, data: Self.chatCompletionPayload(text: "done"))
} operation: { session in
try await OpenAICompatibleHelper.generateText(
request: request,
modelId: "anthropic/claude-fable-5",
baseURL: "https://mock.compatible",
apiKey: "sk-test",
providerName: "OpenRouter",
session: session,
)
}
let bodyJSON = try #require(capture.body).jsonObject()
let messages = try #require(bodyJSON["messages"] as? [[String: Any]])
let assistantMessages = messages.filter { $0["role"] as? String == "assistant" }
#expect(assistantMessages.count == 1)
#expect(assistantMessages.first?["content"] as? String == "visible")
#expect(try String(data: #require(capture.body), encoding: .utf8)?.contains("private reasoning") == false)
}
@Test
func `non-200 responses surface TachikomaError.apiError`() async {
await self.withMockedSession { urlRequest in

View File

@ -207,6 +207,24 @@ enum ProviderOptionsTests {
}
}
@Test
func `Encode and decode Anthropic adaptive thinking mode`() throws {
let original = AnthropicOptions(
thinking: .adaptive,
)
let encoder = JSONEncoder()
let data = try encoder.encode(original)
let decoder = JSONDecoder()
let decoded = try decoder.decode(AnthropicOptions.self, from: data)
guard case .adaptive = decoded.thinking else {
Issue.record("Expected thinking to be adaptive")
return
}
}
@Test
func `Encode and decode provider options container`() throws {
let original = ProviderOptions(

View File

@ -50,6 +50,8 @@ enum ProviderTests {
#expect(Provider.groq.identifier == "groq")
#expect(Provider.mistral.identifier == "mistral")
#expect(Provider.google.identifier == "google")
#expect(Provider.minimax.identifier == "minimax")
#expect(Provider.minimaxCN.identifier == "minimax-cn")
#expect(Provider.ollama.identifier == "ollama")
#expect(Provider.azureOpenAI.identifier == "azure-openai")
}
@ -68,6 +70,8 @@ enum ProviderTests {
#expect(Provider.groq.displayName == "Groq")
#expect(Provider.mistral.displayName == "Mistral")
#expect(Provider.google.displayName == "Google")
#expect(Provider.minimax.displayName == "MiniMax")
#expect(Provider.minimaxCN.displayName == "MiniMax China")
#expect(Provider.ollama.displayName == "Ollama")
#expect(Provider.azureOpenAI.displayName == "Azure OpenAI")
#expect(Provider.custom("test").displayName == "Test")
@ -81,6 +85,8 @@ enum ProviderTests {
#expect(Provider.groq.environmentVariable == "GROQ_API_KEY")
#expect(Provider.mistral.environmentVariable == "MISTRAL_API_KEY")
#expect(Provider.google.environmentVariable == "GEMINI_API_KEY")
#expect(Provider.minimax.environmentVariable == "MINIMAX_API_KEY")
#expect(Provider.minimaxCN.environmentVariable == "MINIMAX_CN_API_KEY")
#expect(Provider.ollama.environmentVariable == "OLLAMA_API_KEY")
#expect(Provider.azureOpenAI.environmentVariable == "AZURE_OPENAI_API_KEY")
#expect(Provider.custom("test").environmentVariable.isEmpty)
@ -90,6 +96,7 @@ enum ProviderTests {
func `Alternative environment variables`() {
#expect(Provider.grok.alternativeEnvironmentVariables == ["XAI_API_KEY", "GROK_API_KEY"])
#expect(Provider.google.alternativeEnvironmentVariables == ["GOOGLE_API_KEY"])
#expect(Provider.minimaxCN.alternativeEnvironmentVariables == ["MINIMAX_API_KEY"])
#expect(Provider.openai.alternativeEnvironmentVariables.isEmpty)
#expect(Provider.anthropic.alternativeEnvironmentVariables.isEmpty)
#expect(Provider.azureOpenAI.alternativeEnvironmentVariables == [
@ -106,6 +113,8 @@ enum ProviderTests {
#expect(Provider.groq.defaultBaseURL == "https://api.groq.com/openai/v1")
#expect(Provider.mistral.defaultBaseURL == "https://api.mistral.ai/v1")
#expect(Provider.google.defaultBaseURL == "https://generativelanguage.googleapis.com/v1beta")
#expect(Provider.minimax.defaultBaseURL == "https://api.minimax.io/anthropic")
#expect(Provider.minimaxCN.defaultBaseURL == "https://api.minimaxi.com/anthropic")
#expect(Provider.ollama.defaultBaseURL == "http://localhost:11434")
#expect(Provider.azureOpenAI.defaultBaseURL == nil)
#expect(Provider.custom("test").defaultBaseURL == nil)
@ -119,6 +128,8 @@ enum ProviderTests {
#expect(Provider.groq.requiresAPIKey == true)
#expect(Provider.mistral.requiresAPIKey == true)
#expect(Provider.google.requiresAPIKey == true)
#expect(Provider.minimax.requiresAPIKey == true)
#expect(Provider.minimaxCN.requiresAPIKey == true)
#expect(Provider.ollama.requiresAPIKey == false) // Ollama typically doesn't require API key
#expect(Provider.azureOpenAI.requiresAPIKey == true)
#expect(Provider.custom("test").requiresAPIKey == true) // Assume custom providers need keys
@ -134,6 +145,9 @@ enum ProviderTests {
#expect(Provider.from(identifier: "groq") == .groq)
#expect(Provider.from(identifier: "mistral") == .mistral)
#expect(Provider.from(identifier: "google") == .google)
#expect(Provider.from(identifier: "minimax") == .minimax)
#expect(Provider.from(identifier: "minimax-cn") == .minimaxCN)
#expect(Provider.from(identifier: "minimaxi") == .minimaxCN)
#expect(Provider.from(identifier: "ollama") == .ollama)
#expect(Provider.from(identifier: "azure-openai") == .azureOpenAI)
}
@ -172,6 +186,8 @@ enum ProviderTests {
.groq,
.mistral,
.google,
.minimax,
.minimaxCN,
.ollama,
.azureOpenAI,
]
@ -194,6 +210,18 @@ enum ProviderTests {
#expect(provider.alternativeEnvironmentVariables == ["XAI_API_KEY", "GROK_API_KEY"])
}
@Test
func `MiniMax China falls back to MiniMax environment variable`() async {
let resolved = await withTemporaryEnvironment([
"MINIMAX_CN_API_KEY": nil,
"MINIMAX_API_KEY": "shared-minimax-key",
]) {
Provider.minimaxCN.loadAPIKeyFromEnvironment()
}
#expect(resolved == "shared-minimax-key")
}
@Test
func `Custom providers don't have environment variables`() {
let customProvider = Provider.custom("test")

View File

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

View File

@ -180,6 +180,30 @@ struct StopConditionsTests {
#expect(!collectedText.contains("ignored"))
}
@Test
func `Stop conditions finish immediately after local match`() async throws {
let stream = AsyncThrowingStream<TextStreamDelta, Error> { continuation in
Task {
continuation.yield(TextStreamDelta(type: .textDelta, content: "STOP"))
try? await Task.sleep(nanoseconds: 2_000_000_000)
continuation.yield(TextStreamDelta(type: .textDelta, content: "late"))
continuation.yield(TextStreamDelta(type: .done, finishReason: .length))
continuation.finish()
}
}
let start = Date()
var received: [TextStreamDelta] = []
for try await delta in stream.stopWhen(StringStopCondition("STOP")) {
received.append(delta)
}
#expect(Date().timeIntervalSince(start) < 0.5)
#expect(received.map(\.content).compactMap(\.self) == ["STOP"])
#expect(received.last?.type == .done)
#expect(received.last?.finishReason == .stop)
}
// MARK: - Builder Pattern Tests
@Test

View File

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

View File

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

View File

@ -367,35 +367,35 @@ struct TachikomaConfigurationTests {
Task {
// Uses default (.current)
_ = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [.user("Test")],
)
// Uses explicit
_ = try await generateText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [.user("Test")],
configuration: explicitConfig,
)
// Stream functions
_ = try await streamText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [.user("Test")],
)
_ = try await streamText(
model: .openai(.gpt4o),
model: .openai(.gpt55),
messages: [.user("Test")],
configuration: explicitConfig,
)
// Convenience functions
_ = try await generate("Test", using: .openai(.gpt4o))
_ = try await generate("Test", using: .openai(.gpt4o), configuration: explicitConfig)
_ = try await generate("Test", using: .openai(.gpt55))
_ = try await generate("Test", using: .openai(.gpt55), configuration: explicitConfig)
_ = try await stream("Test", using: .openai(.gpt4o))
_ = try await stream("Test", using: .openai(.gpt4o), configuration: explicitConfig)
_ = try await stream("Test", using: .openai(.gpt55))
_ = try await stream("Test", using: .openai(.gpt55), configuration: explicitConfig)
}
}
@ -559,9 +559,9 @@ struct TachikomaConfigurationTests {
return DummyProvider()
}
let provider = try config.makeProvider(for: .openai(.gpt4o))
let provider = try config.makeProvider(for: .openai(.gpt55))
#expect(provider is DummyProvider)
#expect(capturedModel == .openai(.gpt4o))
#expect(capturedModel == .openai(.gpt55))
}
@Test
@ -569,8 +569,8 @@ struct TachikomaConfigurationTests {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("mock-key", for: .openai)
let provider = try config.makeProvider(for: .openai(.gpt4o))
#expect(provider is OpenAIProvider)
let provider = try config.makeProvider(for: .openai(.gpt55))
#expect(provider is OpenAIResponsesProvider)
}
}
}

View File

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

View File

@ -59,6 +59,67 @@ struct ResponseCacheTests {
#expect(cached?.finishReason == .stop)
}
@Test
func `ResponseCache keys include reasoning metadata`() async {
let cache = ResponseCache()
let response = ProviderResponse(text: "cached", usage: nil, finishReason: .stop)
func request(signature: String) -> ProviderRequest {
ProviderRequest(
messages: [
.user("Hello"),
ModelMessage(
role: .assistant,
content: [.text("thinking")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.signature": signature,
"anthropic.thinking.type": "thinking",
]),
),
.assistant("Hi"),
],
tools: nil,
settings: .default,
)
}
await cache.store(response, for: request(signature: "sig-a"))
#expect(await cache.get(for: request(signature: "sig-a"))?.text == "cached")
#expect(await cache.get(for: request(signature: "sig-b")) == nil)
}
@Test
func `CacheEntry size includes reasoning and assistant messages`() {
let small = CacheEntry(response: ProviderResponse(text: "ok"))
let largePayload = String(repeating: "x", count: 4096)
let large = CacheEntry(response: ProviderResponse(
text: "ok",
reasoning: [
ProviderReasoningBlock(text: largePayload, signature: largePayload, type: "thinking"),
ProviderReasoningBlock(
text: "",
type: "openrouter_reasoning_details",
rawJSON: largePayload,
),
],
assistantMessages: [
ModelMessage(
role: .assistant,
content: [.text(largePayload)],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.model": "claude-fable-5",
"anthropic.thinking.signature": largePayload,
]),
),
],
))
#expect(large.estimatedMemorySize() > small.estimatedMemorySize() + 12000)
}
@Test
func `ResponseCache cache miss`() async {
let cache = ResponseCache()
@ -267,6 +328,99 @@ struct ResponseCacheTests {
#expect(key1.hash != key3.hash)
}
@Test
func `CacheKey includes reasoning effort and Anthropic thinking options`() {
let messages = [ModelMessage.user("Hello")]
let lowEffort = ProviderRequest(
messages: messages,
settings: GenerationSettings(
reasoningEffort: .low,
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
),
)
let highEffort = ProviderRequest(
messages: messages,
settings: GenerationSettings(
reasoningEffort: .high,
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
),
)
let disabledThinking = ProviderRequest(
messages: messages,
settings: GenerationSettings(
reasoningEffort: .low,
providerOptions: .init(anthropic: .init(thinking: .disabled)),
),
)
#expect(CacheKey(from: lowEffort).hash != CacheKey(from: highEffort).hash)
#expect(CacheKey(from: lowEffort).hash != CacheKey(from: disabledThinking).hash)
}
@Test
func `CacheKey includes string stop condition values`() {
let endRequest = ProviderRequest(
messages: [ModelMessage.user("Hello")],
settings: GenerationSettings(stopConditions: StringStopCondition("END")),
)
let stopRequest = ProviderRequest(
messages: [ModelMessage.user("Hello")],
settings: GenerationSettings(stopConditions: StringStopCondition("STOP")),
)
#expect(CacheKey(from: endRequest).hash != CacheKey(from: stopRequest).hash)
}
@Test
func `CacheKey encodes composite stop conditions without delimiter collisions`() async {
let cache = ResponseCache()
let splitRequest = ProviderRequest(
messages: [ModelMessage.user("Hello")],
settings: GenerationSettings(stopConditions: AnyStopCondition(
StringStopCondition("a"),
StringStopCondition("b"),
)),
)
let joinedRequest = ProviderRequest(
messages: [ModelMessage.user("Hello")],
settings: GenerationSettings(stopConditions: AnyStopCondition(
StringStopCondition("a,string:true:b"),
)),
)
#expect(CacheKey(from: splitRequest).hash != CacheKey(from: joinedRequest).hash)
await cache.store(ProviderResponse(text: "split", finishReason: .stop), for: splitRequest)
let joinedCached = await cache.get(for: joinedRequest)
#expect(joinedCached == nil)
}
@Test
func `CacheKey marks custom stop conditions uncacheable`() {
let request = ProviderRequest(
messages: [ModelMessage.user("Hello")],
settings: GenerationSettings(stopConditions: PredicateStopCondition { _, _ in false }),
)
let key = CacheKey(from: request)
#expect(key.isCacheable == false)
}
@Test
func `ResponseCache skips custom stop condition entries`() async {
let cache = ResponseCache()
let request = ProviderRequest(
messages: [ModelMessage.user("Hello")],
settings: GenerationSettings(stopConditions: PredicateStopCondition { _, _ in false }),
)
await cache.store(ProviderResponse(text: "cached", finishReason: .stop), for: request)
let cached = await cache.get(for: request)
#expect(cached == nil)
}
@Test
func `CacheKey includes tools in hash`() {
let tool1 = AgentTool(
@ -317,7 +471,7 @@ struct ResponseCacheTests {
// Create a mock provider
let mockProvider = ResponseCacheMockProvider(
model: .openai(.gpt4o),
model: .openai(.gpt55),
response: ProviderResponse(text: "Cached response", usage: nil, finishReason: .stop),
)
@ -336,7 +490,7 @@ struct ResponseCacheTests {
// Use a simple counter that can be modified in the closure
let callCount = Box(value: 0)
var mockProvider = ResponseCacheMockProvider(
model: .openai(.gpt4o),
model: .openai(.gpt55),
response: ProviderResponse(text: "Response", usage: nil, finishReason: .stop),
)
mockProvider.onGenerateText = { _ in
@ -362,13 +516,45 @@ struct ResponseCacheTests {
#expect(callCount.value == 1) // Provider not called again
}
@Test
func `CachedProvider keys include provider endpoint identity`() async throws {
let cache = ResponseCache()
let callCountA = Box(value: 0)
let callCountB = Box(value: 0)
var providerA = ResponseCacheMockProvider(
model: .openaiCompatible(modelId: "shared-model", baseURL: "https://gateway.test/v1?tenant=a"),
response: ProviderResponse(text: "tenant-a", usage: nil, finishReason: .stop),
mockModelId: "shared-model",
mockBaseURL: "https://gateway.test/v1?tenant=a",
)
var providerB = ResponseCacheMockProvider(
model: .openaiCompatible(modelId: "shared-model", baseURL: "https://gateway.test/v1?tenant=b"),
response: ProviderResponse(text: "tenant-b", usage: nil, finishReason: .stop),
mockModelId: "shared-model",
mockBaseURL: "https://gateway.test/v1?tenant=b",
)
providerA.onGenerateText = { _ in callCountA.value += 1 }
providerB.onGenerateText = { _ in callCountB.value += 1 }
let cachedA = await cache.wrapProvider(providerA)
let cachedB = await cache.wrapProvider(providerB)
let request = ProviderRequest(messages: [ModelMessage.user("Test")], tools: nil, settings: .default)
#expect(try await cachedA.generateText(request: request).text == "tenant-a")
#expect(try await cachedB.generateText(request: request).text == "tenant-b")
#expect(try await cachedA.generateText(request: request).text == "tenant-a")
#expect(try await cachedB.generateText(request: request).text == "tenant-b")
#expect(callCountA.value == 1)
#expect(callCountB.value == 1)
}
@Test
func `CachedProvider doesn't cache streaming`() async throws {
let cache = ResponseCache()
let callCount = Box(value: 0)
var mockProvider = ResponseCacheMockProvider(
model: .openai(.gpt4o),
model: .openai(.gpt55),
response: ProviderResponse(text: "Test", usage: nil, finishReason: .stop),
)
mockProvider.onStreamText = { _ in
@ -397,15 +583,17 @@ struct ResponseCacheTests {
private struct ResponseCacheMockProvider: ModelProvider {
let model: LanguageModel
let response: ProviderResponse
let mockModelId: String
let mockBaseURL: String?
var onGenerateText: (@Sendable (ProviderRequest) -> Void)?
var onStreamText: (@Sendable (ProviderRequest) -> Void)?
var modelId: String {
"mock-model"
self.mockModelId
}
var baseURL: String? {
nil
self.mockBaseURL
}
var apiKey: String? {
@ -419,11 +607,15 @@ private struct ResponseCacheMockProvider: ModelProvider {
init(
model: LanguageModel,
response: ProviderResponse,
mockModelId: String = "mock-model",
mockBaseURL: String? = nil,
onGenerateText: (@Sendable (ProviderRequest) -> Void)? = nil,
onStreamText: (@Sendable (ProviderRequest) -> Void)? = nil,
) {
self.model = model
self.response = response
self.mockModelId = mockModelId
self.mockBaseURL = mockBaseURL
self.onGenerateText = onGenerateText
self.onStreamText = onStreamText
}

View File

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

View File

@ -7,7 +7,7 @@ struct LMStudioProviderTests {
func `Provider initialization`() {
let provider = LMStudioProvider(
baseURL: "http://localhost:1234/v1",
modelId: "gpt-oss-120b",
modelId: "openai/gpt-oss-120b",
apiKey: nil,
)
@ -18,7 +18,7 @@ struct LMStudioProviderTests {
let capabilities = provider.capabilities
#expect(baseURL == "http://localhost:1234/v1")
#expect(modelId == "gpt-oss-120b")
#expect(modelId == "openai/gpt-oss-120b")
#expect(apiKey == nil)
#expect(capabilities.supportsTools == true)
#expect(capabilities.supportsStreaming == true)
@ -28,11 +28,11 @@ struct LMStudioProviderTests {
func `Model enum integration`() {
let model1 = LanguageModel.lmstudio(.gptOSS120B)
let model2 = LanguageModel.lmstudio(.gptOSS20B)
let model3 = LanguageModel.lmstudio(.current)
let model3 = LanguageModel.lmstudio(.llama3370B)
#expect(model1.modelId == "gpt-oss-120b")
#expect(model2.modelId == "gpt-oss-20b")
#expect(model3.modelId == "current")
#expect(model1.modelId == "openai/gpt-oss-120b")
#expect(model2.modelId == "openai/gpt-oss-20b")
#expect(model3.modelId == "meta/llama-3.3-70b")
#expect(model1.supportsTools == true)
#expect(model1.contextLength == 128_000)
@ -47,7 +47,7 @@ struct LMStudioProviderTests {
#expect(model2.providerName == "LMStudio")
#expect(model1.modelId == "gpt-oss:120b")
#expect(model2.modelId == "gpt-oss-120b")
#expect(model2.modelId == "openai/gpt-oss-120b")
}
@Test
@ -59,7 +59,7 @@ struct LMStudioProviderTests {
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
let modelId = provider.modelId
#expect(modelId == "gpt-oss-120b")
#expect(modelId == "openai/gpt-oss-120b")
// Should work without API key (local model)
#expect(provider is LMStudioProvider)

View File

@ -1,4 +1,7 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import Testing
@testable import Tachikoma
@ -22,6 +25,19 @@ struct AnthropicInterleavedDefaultsTests {
#expect(parts.contains("fine-grained-tool-streaming-2025-05-14"))
}
@Test
func `Endpoint identity includes routing query without exposing raw values`() {
let tenantA = ReasoningEndpointIdentity.canonical("https://gateway.test/v1?tenant=a")
let tenantB = ReasoningEndpointIdentity.canonical("https://gateway.test/v1?tenant=b")
#expect(tenantA != tenantB)
#expect(tenantA?.hasPrefix("sha256:") == true)
#expect(tenantA?.contains("tenant") == false)
#expect(tenantA?.contains("gateway") == false)
#expect(ReasoningEndpointIdentity.canonical("https://gateway.test/v1/?tenant=a") == tenantA)
#expect(ReasoningEndpointIdentity.canonical("https://user:secret@gateway.test/v1?tenant=a#frag") == tenantA)
}
@Test
func `Provider request includes beta header and thinking payload`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
@ -57,6 +73,358 @@ struct AnthropicInterleavedDefaultsTests {
#expect(thinking["budget_tokens"] as? Int == 12000)
}
@Test
func `Opus 4_7 request strips unsupported sampling and uses adaptive thinking`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus47, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
temperature: 0.7,
topP: 0.9,
topK: 40,
providerOptions: .init(anthropic: .init(thinking: .enabled(budgetTokens: 12000))),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(json["model"] as? String == "claude-opus-4-7")
#expect(json["temperature"] == nil)
#expect(json["top_p"] == nil)
#expect(json["top_k"] == nil)
let thinking = try #require(json["thinking"] as? [String: Any])
#expect(thinking["type"] as? String == "adaptive")
#expect(thinking["budget_tokens"] == nil)
let outputConfig = try #require(json["output_config"] as? [String: Any])
#expect(outputConfig["effort"] as? String == "medium")
#expect(json["max_tokens"] as? Int == 64)
}
@Test
func `Opus 4_8 request strips unsupported sampling and uses adaptive thinking`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus48, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
temperature: 0.7,
topP: 0.9,
topK: 40,
reasoningEffort: .low,
providerOptions: .init(anthropic: .init(thinking: .enabled(budgetTokens: 12000))),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(json["model"] as? String == "claude-opus-4-8")
#expect(json["temperature"] == nil)
#expect(json["top_p"] == nil)
#expect(json["top_k"] == nil)
let thinking = try #require(json["thinking"] as? [String: Any])
#expect(thinking["type"] as? String == "adaptive")
#expect(thinking["budget_tokens"] == nil)
let outputConfig = try #require(json["output_config"] as? [String: Any])
#expect(outputConfig["effort"] as? String == "low")
#expect(json["max_tokens"] as? Int == 64)
}
@Test
func `Fable 5 request omits thinking config and uses effort output config`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let settings = GenerationSettings(
maxTokens: 128_000,
temperature: 0.7,
topP: 0.9,
topK: 40,
reasoningEffort: .high,
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(json["model"] as? String == "claude-fable-5")
#expect(json["temperature"] == nil)
#expect(json["top_p"] == nil)
#expect(json["top_k"] == nil)
#expect(json["thinking"] == nil)
let outputConfig = try #require(json["output_config"] as? [String: Any])
#expect(outputConfig["effort"] as? String == "high")
#expect(json["max_tokens"] as? Int == 128_000)
#expect(urlRequest.value(forHTTPHeaderField: "anthropic-beta") == nil)
}
@Test
func `Fable 5 request uses model-aware default output budget`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let request = ProviderRequest(messages: [.user("hi")])
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(json["max_tokens"] as? Int == 16384)
#expect(urlRequest.timeoutInterval == 1800)
}
@Test
func `Fable 5 long output requests extend non-streaming timeout`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let urlRequest = try provider.makeURLRequest(
for: ProviderRequest(
messages: [.user("long")],
settings: GenerationSettings(maxTokens: 128_000),
),
stream: false,
)
#expect(urlRequest.timeoutInterval == 1800)
}
@Test
func `Opus long output requests extend non-streaming timeout`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
for model in [LanguageModel.Anthropic.opus47, .opus48] {
let provider = try AnthropicProvider(model: model, configuration: config)
let urlRequest = try provider.makeURLRequest(
for: ProviderRequest(
messages: [.user("long")],
settings: GenerationSettings(maxTokens: 128_000),
),
stream: false,
)
#expect(urlRequest.timeoutInterval == 1800)
}
}
@Test
func `Custom Fable model id uses Fable request defaults`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .custom("claude-fable-5"), configuration: config)
let request = ProviderRequest(messages: [.user("hi")])
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(provider.capabilities.supportsStreaming == false)
#expect(provider.capabilities.contextLength == 1_000_000)
#expect(provider.capabilities.maxOutputTokens == 128_000)
#expect(LanguageModel.anthropic(.custom("claude-fable-5")).supportsStreaming == false)
#expect(LanguageModel.anthropic(.custom("claude-fable-5")).contextLength == 1_000_000)
#expect(LanguageModel.Anthropic.custom("claude-fable-5").maxOutputTokens == 128_000)
#expect(json["model"] as? String == "claude-fable-5")
#expect(json["thinking"] == nil)
#expect(json["max_tokens"] as? Int == 16384)
}
@Test
func `Qualified custom Fable model id uses Fable request defaults`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .custom("anthropic.claude-fable-5"), configuration: config)
let request = ProviderRequest(messages: [.user("hi")])
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(provider.capabilities.supportsStreaming == false)
#expect(provider.capabilities.contextLength == 1_000_000)
#expect(provider.capabilities.maxOutputTokens == 128_000)
#expect(LanguageModel.anthropic(.custom("anthropic.claude-fable-5")).contextLength == 1_000_000)
#expect(LanguageModel.Anthropic.custom("anthropic.claude-fable-5").maxOutputTokens == 128_000)
#expect(json["model"] as? String == "anthropic.claude-fable-5")
#expect(json["thinking"] == nil)
#expect(json["max_tokens"] as? Int == 16384)
}
@Test
func `Fable 5 rejects disabled thinking mode`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
providerOptions: .init(anthropic: .init(thinking: .disabled)),
)
#expect(throws: TachikomaError.self) {
_ = try provider.makeURLRequest(
for: ProviderRequest(messages: [.user("hi")], settings: settings),
stream: false,
)
}
}
@Test
func `Custom Fable model id rejects disabled thinking mode`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .custom("claude-fable-5"), configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
providerOptions: .init(anthropic: .init(thinking: .disabled)),
)
#expect(throws: TachikomaError.self) {
_ = try provider.makeURLRequest(
for: ProviderRequest(messages: [.user("hi")], settings: settings),
stream: false,
)
}
}
@Test
func `Opus reasoning effort is kept when thinking is disabled`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus48, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
reasoningEffort: .low,
providerOptions: .init(anthropic: .init(thinking: .disabled)),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let outputConfig = try #require(json["output_config"] as? [String: Any])
#expect(json["thinking"] == nil)
#expect(outputConfig["effort"] as? String == "low")
}
@Test
func `Opus effort is sent without thinking when reasoning effort is configured`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus48, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
reasoningEffort: .low,
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let outputConfig = try #require(json["output_config"] as? [String: Any])
#expect(json["thinking"] == nil)
#expect(outputConfig["effort"] as? String == "low")
}
@Test
func `Unsupported adaptive thinking is omitted for older Claude models`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus45, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
#expect(json["thinking"] == nil)
#expect(json["output_config"] == nil)
}
@Test
func `Sonnet 4_6 request keeps adaptive thinking payload`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .sonnet46, configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
temperature: 0.7,
reasoningEffort: .medium,
providerOptions: .init(anthropic: .init(thinking: .adaptive)),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let thinking = try #require(json["thinking"] as? [String: Any])
let outputConfig = try #require(json["output_config"] as? [String: Any])
#expect(json["model"] as? String == "claude-sonnet-4-6")
#expect(json["temperature"] == nil)
#expect(thinking["type"] as? String == "adaptive")
#expect(outputConfig["effort"] as? String == "medium")
}
@Test
func `Custom Anthropic request keeps thinking payload`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .custom("claude-opus-4-5-latest"), configuration: config)
let settings = GenerationSettings(
maxTokens: 64,
providerOptions: .init(anthropic: .init(thinking: .enabled(budgetTokens: 12000))),
)
let request = ProviderRequest(
messages: [.user("hi")],
settings: settings,
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let thinking = try #require(json["thinking"] as? [String: Any])
#expect(json["model"] as? String == "claude-opus-4-5-latest")
#expect(thinking["type"] as? String == "enabled")
#expect(thinking["budget_tokens"] as? Int == 12000)
}
@Test
func `Provider respects custom baseURL`() throws {
let config = TachikomaConfiguration(
@ -70,6 +438,24 @@ struct AnthropicInterleavedDefaultsTests {
#expect(urlRequest.url?.absoluteString == "https://entropic.example/v1/messages")
}
@Test
func `Provider includes additional proxy headers`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(
model: .opus45,
configuration: config,
additionalHeaders: [
"client_id": "proxy-client",
"client_secret": "proxy-secret",
],
)
let request = ProviderRequest(messages: [.user("hi")])
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
#expect(urlRequest.value(forHTTPHeaderField: "client_id") == "proxy-client")
#expect(urlRequest.value(forHTTPHeaderField: "client_secret") == "proxy-secret")
}
@Test
func `Stream delta decodes thinking_delta payload`() throws {
let data = try #require("{\"type\":\"thinking_delta\",\"thinking\":\"ok\"}".data(using: .utf8))
@ -87,6 +473,29 @@ struct AnthropicInterleavedDefaultsTests {
#expect(delta.signature == "sig")
}
@Test
func `Stream delta decodes message_delta stop reason without delta type`() throws {
let data = try #require(
"{\"stop_reason\":\"refusal\",\"stop_sequence\":null}".data(using: .utf8),
)
let delta = try JSONDecoder().decode(AnthropicStreamDelta.self, from: data)
#expect(delta.type.isEmpty)
#expect(delta.stopReason == "refusal")
}
@Test
func `Stream event decodes partial usage with stop reason`() throws {
let data = try #require(
#"{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":42}}"#
.data(using: .utf8),
)
let event = try JSONDecoder().decode(AnthropicStreamEvent.self, from: data)
#expect(event.delta?.stopReason == "end_turn")
#expect(event.usage?.inputTokens == 0)
#expect(event.usage?.outputTokens == 42)
}
@Test
func `Signed thinking blocks are preserved for assistant messages`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
@ -125,7 +534,125 @@ struct AnthropicInterleavedDefaultsTests {
}
@Test
func `Redacted thinking blocks preserve signature without text`() throws {
func `Fable 5 preserves signed thinking history while omitting request thinking field`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let signedThinking = try ModelMessage(
role: .assistant,
content: [.text("fable thinking")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.model": "claude-fable-5",
"anthropic.thinking.signature": "sig-fable",
"anthropic.thinking.type": "thinking",
"tachikoma.reasoning.provider": "anthropic",
"tachikoma.reasoning.model": "claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://api.anthropic.com")),
]),
)
let request = ProviderRequest(
messages: [.user("hi"), signedThinking, .assistant("hello"), .user("continue")],
settings: GenerationSettings(maxTokens: 64),
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let messages = try #require(json["messages"] as? [[String: Any]])
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
#expect(json["thinking"] == nil)
#expect(assistant.first?["type"] as? String == "thinking")
#expect(assistant.first?["thinking"] as? String == "fable thinking")
#expect(assistant.first?["signature"] as? String == "sig-fable")
}
@Test
func `Fable 5 drops mismatched signed thinking history in direct provider requests`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let signedThinking = try ModelMessage(
role: .assistant,
content: [.text("foreign thinking")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.model": "claude-fable-5",
"anthropic.thinking.signature": "sig-foreign",
"anthropic.thinking.type": "thinking",
"tachikoma.reasoning.provider": "anthropic",
"tachikoma.reasoning.model": "claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://other.example.test")),
]),
)
let request = ProviderRequest(
messages: [.user("hi"), signedThinking, .assistant("hello"), .user("continue")],
settings: GenerationSettings(maxTokens: 64),
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let messages = try #require(json["messages"] as? [[String: Any]])
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
#expect(assistant.count == 1)
#expect(assistant.first?["type"] as? String == "text")
#expect(assistant.first?["text"] as? String == "hello")
#expect(String(data: body, encoding: .utf8)?.contains("foreign thinking") == false)
}
@Test
func `Fable 5 rejects assistant prefill requests`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
#expect(throws: TachikomaError.self) {
_ = try provider.makeURLRequest(
for: ProviderRequest(messages: [.user("hi"), .assistant("prefill")]),
stream: false,
)
}
}
@Test
func `Anthropic refusal stop reason maps to content filter`() {
#expect(AnthropicProvider.mapFinishReason("refusal") == .contentFilter)
#expect(AnthropicProvider.mapFinishReason("model_context_window_exceeded") == .length)
}
@Test
func `Anthropic refusal response decodes stop details explanation`() throws {
let data = """
{
"id": "msg_test",
"type": "message",
"role": "assistant",
"content": [],
"model": "claude-fable-5",
"stop_reason": "refusal",
"stop_details": {
"category": "cyber",
"explanation": "I cannot help with that request."
},
"usage": {
"input_tokens": 10,
"output_tokens": 0
}
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(AnthropicMessageResponse.self, from: data)
#expect(response.stopDetails?.category == "cyber")
#expect(response.stopDetails?.explanation == "I cannot help with that request.")
}
@Test
func `Redacted thinking blocks preserve opaque data`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .opus45, configuration: config)
@ -136,10 +663,9 @@ struct AnthropicInterleavedDefaultsTests {
let redacted = ModelMessage(
role: .assistant,
content: [.text("")],
content: [.text("opaque-redacted-data")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.signature": "sig-redacted",
"anthropic.thinking.type": "redacted_thinking",
]),
)
@ -156,8 +682,232 @@ struct AnthropicInterleavedDefaultsTests {
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
#expect(assistant.first?["type"] as? String == "redacted_thinking")
#expect((assistant.first?["redacted_thinking"] as? String)?.isEmpty == true)
#expect(assistant.first?["signature"] as? String == "sig-redacted")
#expect(assistant.first?["data"] as? String == "opaque-redacted-data")
#expect(assistant.first?["signature"] == nil)
}
@Test
func `Redacted thinking response decodes opaque data`() throws {
let data = try #require(
"""
{"type":"redacted_thinking","data":"opaque-redacted-data"}
""".data(using: .utf8),
)
let content = try JSONDecoder().decode(AnthropicResponseContent.self, from: data)
guard case let .redactedThinking(redacted) = content else {
Issue.record("Expected redacted thinking content")
return
}
#expect(redacted.data == "opaque-redacted-data")
}
@Test
func `Consecutive thinking blocks are preserved in order`() throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let signedThinking = try ModelMessage(
role: .assistant,
content: [.text("signed")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.model": "claude-fable-5",
"anthropic.thinking.signature": "sig",
"anthropic.thinking.type": "thinking",
"tachikoma.reasoning.provider": "anthropic",
"tachikoma.reasoning.model": "claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://api.anthropic.com")),
]),
)
let redactedThinking = try ModelMessage(
role: .assistant,
content: [.text("opaque")],
channel: .thinking,
metadata: .init(customData: [
"anthropic.thinking.model": "claude-fable-5",
"anthropic.thinking.type": "redacted_thinking",
"tachikoma.reasoning.provider": "anthropic",
"tachikoma.reasoning.model": "claude-fable-5",
"tachikoma.reasoning.base_url": #require(ReasoningEndpointIdentity
.canonical("https://api.anthropic.com")),
]),
)
let request = ProviderRequest(
messages: [.user("hi"), signedThinking, redactedThinking, .assistant("hello"), .user("continue")],
settings: GenerationSettings(maxTokens: 64),
)
let urlRequest = try provider.makeURLRequest(for: request, stream: false)
let body = try #require(urlRequest.httpBody)
let json = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any])
let messages = try #require(json["messages"] as? [[String: Any]])
let assistant = try #require(messages[1]["content"] as? [[String: Any]])
#expect(assistant.count == 3)
#expect(assistant[0]["type"] as? String == "thinking")
#expect(assistant[0]["thinking"] as? String == "signed")
#expect(assistant[0]["signature"] as? String == "sig")
#expect(assistant[1]["type"] as? String == "redacted_thinking")
#expect(assistant[1]["data"] as? String == "opaque")
#expect(assistant[2]["type"] as? String == "text")
#expect(assistant[2]["text"] as? String == "hello")
}
@Test
func `Current Anthropic models expose documented output caps`() {
#expect(LanguageModel.Anthropic.fable5.maxOutputTokens == 128_000)
#expect(LanguageModel.Anthropic.opus47.maxOutputTokens == 128_000)
#expect(LanguageModel.Anthropic.opus48.maxOutputTokens == 128_000)
#expect(LanguageModel.Anthropic.sonnet46.maxOutputTokens == 64000)
#expect(LanguageModel.Anthropic.haiku45.maxOutputTokens == 64000)
}
@Test
func `Fable and Opus 4_8 streaming are disabled until rollback is supported`() async throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic": "test-key"])
let provider = try AnthropicProvider(model: .fable5, configuration: config)
let opusProvider = try AnthropicProvider(model: .opus48, configuration: config)
#expect(provider.capabilities.supportsStreaming == false)
#expect(LanguageModel.anthropic(.fable5).supportsStreaming == false)
#expect(opusProvider.capabilities.supportsStreaming == false)
#expect(LanguageModel.anthropic(.opus47).supportsStreaming == true)
#expect(LanguageModel.anthropic(.opus48).supportsStreaming == false)
#expect(LanguageModel.anthropic(.sonnet46).supportsStreaming == true)
#expect(LanguageModel.anthropic(.sonnet45).supportsStreaming == true)
#expect(LanguageModel.anthropic(.haiku45).supportsStreaming == true)
await #expect(throws: TachikomaError.self) {
_ = try await provider.streamText(request: ProviderRequest(messages: [.user("hi")]))
}
await #expect(throws: TachikomaError.self) {
_ = try await opusProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
}
}
@Test
func `Opus 4_8 detection avoids substring false positives`() {
#expect(LanguageModel.Anthropic.isOpus48(modelId: "claude-opus-4-8") == true)
#expect(LanguageModel.Anthropic.isOpus48(modelId: "anthropic/claude-opus-4.8") == true)
#expect(LanguageModel.Anthropic.isOpus48(modelId: "my-opus48-distill") == false)
#expect(LanguageModel.Anthropic.isOpus48(modelId: "opus480") == false)
}
@Test
func `Fable detection avoids substring false positives`() {
#expect(LanguageModel.Anthropic.isFable(modelId: "claude-fable-5") == true)
#expect(LanguageModel.Anthropic.isFable(modelId: "anthropic/claude-fable-5") == true)
#expect(LanguageModel.Anthropic.isFable(modelId: "vendor/claude-fable-50") == false)
#expect(LanguageModel.Anthropic.isFable(modelId: "my-claude-fable-5-distill") == false)
}
@Test
func `Anthropic-compatible provider tags native thinking with wrapper identity`() async throws {
let sessionConfig = URLSessionConfiguration.ephemeral
sessionConfig.protocolClasses = [AnthropicIdentityURLProtocol.self]
let provider = try AnthropicProvider(
model: .custom("claude-fable-5"),
configuration: TachikomaConfiguration(apiKeys: ["anthropic": "test-key"]),
reasoningProvider: "anthropic-compatible",
reasoningModelId: "claude-fable-5",
reasoningBaseURL: "https://user:secret@example.test/path?token=secret#frag",
urlSession: URLSession(configuration: sessionConfig),
)
let response = try await provider.generateText(request: ProviderRequest(messages: [.user("hi")]))
let thinking = try #require(response.assistantMessages.first { $0.channel == .thinking })
#expect(thinking.metadata?.customData?["tachikoma.reasoning.provider"] == "anthropic-compatible")
#expect(thinking.metadata?.customData?["tachikoma.reasoning.model"] == "claude-fable-5")
let endpointIdentity = thinking.metadata?.customData?["tachikoma.reasoning.base_url"]
#expect(endpointIdentity == ReasoningEndpointIdentity.canonical("https://example.test/path?token=secret"))
#expect(endpointIdentity?.hasPrefix("sha256:") == true)
#expect(endpointIdentity?.contains("path") == false)
#expect(endpointIdentity?.contains("secret") == false)
#expect(endpointIdentity?.contains("token") == false)
#expect(thinking.metadata?.customData?["anthropic.thinking.signature"] == "sig")
}
@Test
func `Compatible refusal-prone Anthropic streaming and capabilities are disabled`() async throws {
let config = TachikomaConfiguration(apiKeys: ["anthropic_compatible": "test-key"])
let provider = try AnthropicCompatibleProvider(
modelId: "claude-fable-5",
baseURL: "https://example.test",
configuration: config,
)
let opusProvider = try AnthropicCompatibleProvider(
modelId: "claude-opus-4-8",
baseURL: "https://example.test",
configuration: config,
)
let overriddenProvider = try AnthropicCompatibleProvider(
modelId: "claude-fable-5",
baseURL: "https://example.test",
configuration: config,
capabilities: ModelCapabilities(supportsStreaming: true),
)
#expect(provider.capabilities.supportsStreaming == false)
#expect(opusProvider.capabilities.supportsStreaming == false)
#expect(overriddenProvider.capabilities.supportsStreaming == false)
#expect(provider.capabilities.contextLength == 1_000_000)
#expect(provider.capabilities.maxOutputTokens == 128_000)
#expect(LanguageModel.anthropicCompatible(
modelId: "claude-fable-5",
baseURL: "https://example.test",
).supportsStreaming == false)
#expect(LanguageModel.anthropicCompatible(
modelId: "claude-opus-4-8",
baseURL: "https://example.test",
).supportsStreaming == false)
#expect(LanguageModel.openaiCompatible(
modelId: "claude-fable-5",
baseURL: "https://example.test",
).supportsStreaming == false)
#expect(LanguageModel.anthropicCompatible(
modelId: "claude-fable-5",
baseURL: "https://example.test",
).contextLength == 1_000_000)
#expect(LanguageModel.anthropicCompatible(
modelId: "anthropic.claude-fable-5",
baseURL: "https://example.test",
).contextLength == 1_000_000)
let openAICompatibleProvider = try OpenAICompatibleProvider(
modelId: "claude-fable-5",
baseURL: "https://example.test",
configuration: TachikomaConfiguration(apiKeys: ["openai_compatible": "test-key"]),
)
let openRouterProvider = try OpenRouterProvider(
modelId: "anthropic/claude-fable-5",
configuration: TachikomaConfiguration(apiKeys: ["openrouter": "test-key"]),
)
let togetherProvider = try TogetherProvider(
modelId: "anthropic/claude-fable-5",
configuration: TachikomaConfiguration(apiKeys: ["together": "test-key"]),
)
#expect(openAICompatibleProvider.capabilities.supportsStreaming == false)
#expect(openRouterProvider.capabilities.supportsStreaming == false)
#expect(togetherProvider.capabilities.supportsStreaming == false)
#expect(openAICompatibleProvider.capabilities.contextLength == 1_000_000)
#expect(openAICompatibleProvider.capabilities.maxOutputTokens == 128_000)
#expect(openRouterProvider.capabilities.contextLength == 1_000_000)
#expect(openRouterProvider.capabilities.maxOutputTokens == 128_000)
#expect(togetherProvider.capabilities.contextLength == 1_000_000)
#expect(togetherProvider.capabilities.maxOutputTokens == 128_000)
await #expect(throws: TachikomaError.self) {
_ = try await provider.streamText(request: ProviderRequest(messages: [.user("hi")]))
}
await #expect(throws: TachikomaError.self) {
_ = try await openAICompatibleProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
}
await #expect(throws: TachikomaError.self) {
_ = try await openRouterProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
}
await #expect(throws: TachikomaError.self) {
_ = try await togetherProvider.streamText(request: ProviderRequest(messages: [.user("hi")]))
}
}
@Test
@ -187,3 +937,49 @@ struct AnthropicInterleavedDefaultsTests {
#expect(assistant.first?["type"] as? String == "text")
}
}
private final class AnthropicIdentityURLProtocol: URLProtocol {
override class func canInit(with _: URLRequest) -> Bool {
true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard
let url = self.request.url,
let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"],
) else
{
self.client?.urlProtocol(self, didFailWithError: TachikomaError.invalidInput("Missing mock response"))
return
}
let body = """
{
"id": "msg_test",
"type": "message",
"role": "assistant",
"model": "claude-fable-5",
"content": [
{"type": "thinking", "thinking": "private", "signature": "sig"},
{"type": "text", "text": "ok"}
],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 2}
}
""".data(using: .utf8) ?? Data()
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
self.client?.urlProtocol(self, didLoad: body)
self.client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}

View File

@ -20,7 +20,7 @@ private final class AzureTestURLProtocol: URLProtocol {
static let responseBody: Data = """
{
"id": "chatcmpl-azure",
"model": "gpt-4o",
"model": "gpt-5.5",
"choices": [
{
"index": 0,
@ -85,7 +85,7 @@ struct AzureOpenAIProviderTests {
await AzureTestURLProtocol.reset()
let provider = try AzureOpenAIProvider(
deploymentId: "gpt-4o",
deploymentId: "gpt-5.5",
resource: "my-aoai",
apiVersion: "2025-04-01-preview",
endpoint: nil,
@ -99,7 +99,7 @@ struct AzureOpenAIProviderTests {
#expect(response.text == "hello azure")
let sentRequest = await AzureTestURLProtocol.fetchLastRequest()
#expect(sentRequest?.url?.path == "/openai/deployments/gpt-4o/chat/completions")
#expect(sentRequest?.url?.path == "/openai/deployments/gpt-5.5/chat/completions")
if let components = sentRequest?.url.flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) {
let apiVersion = components.queryItems?.first { $0.name == "api-version" }?.value
@ -122,7 +122,7 @@ struct AzureOpenAIProviderTests {
await AzureTestURLProtocol.reset()
let provider = try AzureOpenAIProvider(
deploymentId: "gpt-4o-mini",
deploymentId: "gpt-5-mini",
resource: nil,
apiVersion: "2025-04-01-preview",
endpoint: nil,

View File

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

View File

@ -9,10 +9,10 @@ struct ProviderSystemTests {
@Test
func `Provider Factory - OpenAI Provider Creation`() async throws {
try await TestHelpers.withTestConfiguration(apiKeys: ["openai": "test-key"]) { config in
let model = Model.openai(.gpt4o)
let model = Model.openai(.gpt55)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "gpt-4o")
#expect(provider.modelId == "gpt-5.5")
#expect(provider.capabilities.supportsVision == true)
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsStreaming == true)
@ -22,12 +22,27 @@ struct ProviderSystemTests {
@Test
func `Provider Factory - Anthropic Provider Creation`() async throws {
try await TestHelpers.withTestConfiguration(apiKeys: ["anthropic": "test-key"]) { config in
let model = Model.anthropic(.opus4)
let model = Model.anthropic(.fable5)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "claude-opus-4-1-20250805")
#expect(provider.modelId == "claude-fable-5")
#expect(provider.capabilities.supportsVision == true)
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsStreaming == false)
#expect(provider.capabilities.contextLength == 1_000_000)
#expect(provider.capabilities.maxOutputTokens == 128_000)
}
}
@Test
func `Provider Factory - MiniMax Provider Creation`() async throws {
try await TestHelpers.withTestConfiguration(apiKeys: ["minimax": "test-key"]) { config in
let model = Model.minimax(.m27)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "MiniMax-M2.7")
#expect(provider.capabilities.supportsVision == false)
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsStreaming == true)
}
}
@ -35,10 +50,10 @@ struct ProviderSystemTests {
@Test
func `Provider Factory - Grok Provider Creation`() async throws {
try await TestHelpers.withTestConfiguration(apiKeys: ["grok": "test-key"]) { config in
let model = Model.grok(.grok4FastReasoning)
let model = Model.grok(.grok43)
let provider = try ProviderFactory.createProvider(for: model, configuration: config)
#expect(provider.modelId == "grok-4-fast-reasoning")
#expect(provider.modelId == "grok-4.3")
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsStreaming == true)
}
@ -81,20 +96,39 @@ struct ProviderSystemTests {
let previousOpenAI = getenv("OPENAI_API_KEY").flatMap { String(cString: $0) }
let previousAnthropic = getenv("ANTHROPIC_API_KEY").flatMap { String(cString: $0) }
let previousMiniMax = getenv("MINIMAX_API_KEY").flatMap { String(cString: $0) }
let previousAnthropicCompatible = getenv("ANTHROPIC_COMPATIBLE_API_KEY").flatMap { String(cString: $0) }
let previousGeneric = getenv("API_KEY").flatMap { String(cString: $0) }
unsetenv("OPENAI_API_KEY")
unsetenv("ANTHROPIC_API_KEY")
unsetenv("MINIMAX_API_KEY")
setenv("ANTHROPIC_COMPATIBLE_API_KEY", "generic-compatible-key", 1)
setenv("API_KEY", "generic-key", 1)
defer {
if let previousOpenAI { setenv("OPENAI_API_KEY", previousOpenAI, 1) }
if let previousAnthropic { setenv("ANTHROPIC_API_KEY", previousAnthropic, 1) }
// swiftlint:disable:next statement_position
if let previousMiniMax { setenv("MINIMAX_API_KEY", previousMiniMax, 1) }
else { unsetenv("MINIMAX_API_KEY") }
if let previousAnthropicCompatible {
setenv("ANTHROPIC_COMPATIBLE_API_KEY", previousAnthropicCompatible, 1)
} else {
unsetenv("ANTHROPIC_COMPATIBLE_API_KEY")
}
if let previousGeneric { setenv("API_KEY", previousGeneric, 1) } else { unsetenv("API_KEY") }
}
#expect(throws: TachikomaError.self) {
try OpenAIProvider(model: .gpt4o, configuration: config)
try OpenAIProvider(model: .gpt55, configuration: config)
}
#expect(throws: TachikomaError.self) {
try AnthropicProvider(model: .opus4, configuration: config)
}
#expect(throws: TachikomaError.self) {
try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
}
}
}
@ -102,17 +136,15 @@ struct ProviderSystemTests {
@Test
func `Model Capabilities - Vision Support`() {
#expect(Model.openai(.gpt4o).supportsVision == true)
#expect(Model.openai(.gpt4oMini).supportsVision == true)
#expect(Model.openai(.gpt41).supportsVision == false)
#expect(Model.openai(.gpt55).supportsVision == true)
#expect(Model.openai(.gpt5Mini).supportsVision == true)
#expect(Model.openai(.custom("text-only-openai")).supportsVision == false)
#expect(Model.anthropic(.fable5).supportsVision == true)
#expect(Model.anthropic(.opus4).supportsVision == true)
#expect(Model.anthropic(.sonnet4).supportsVision == true)
#expect(Model.anthropic(.sonnet46).supportsVision == true)
#expect(Model.grok(.grok2Vision).supportsVision == true)
#expect(Model.grok(.grok2Image).supportsVision == true)
#expect(Model.grok(.grok2).supportsVision == false)
#expect(Model.grok(.grok4).supportsVision == false)
#expect(Model.grok(.grok43).supportsVision == true)
#expect(Model.ollama(.llava).supportsVision == true)
#expect(Model.ollama(.llama33).supportsVision == false)
@ -121,13 +153,14 @@ struct ProviderSystemTests {
@Test
func `Model Capabilities - Tool Support`() {
#expect(Model.openai(.gpt4o).supportsTools == true)
#expect(Model.openai(.gpt41).supportsTools == true)
#expect(Model.openai(.gpt55).supportsTools == true)
#expect(Model.openai(.gpt55).supportsTools == true)
#expect(Model.anthropic(.fable5).supportsTools == true)
#expect(Model.anthropic(.opus4).supportsTools == true)
#expect(Model.anthropic(.sonnet4).supportsTools == true)
#expect(Model.anthropic(.sonnet46).supportsTools == true)
#expect(Model.grok(.grok4).supportsTools == true)
#expect(Model.grok(.grok43).supportsTools == true)
#expect(Model.ollama(.llama33).supportsTools == true)
#expect(Model.ollama(.llava).supportsTools == false) // Vision models don't support tools
@ -136,9 +169,28 @@ struct ProviderSystemTests {
@Test
func `Model Capabilities - Streaming Support`() {
#expect(Model.openai(.gpt4o).supportsStreaming == true)
#expect(Model.openai(.gpt55).supportsStreaming == true)
#expect(Model.anthropic(.opus4).supportsStreaming == true)
#expect(Model.grok(.grok4).supportsStreaming == true)
#expect(Model.anthropic(.opus47).supportsStreaming == true)
#expect(Model.anthropic(.opus48).supportsStreaming == false)
#expect(Model.anthropic(.fable5).supportsStreaming == false)
#expect(Model.openRouter(modelId: "anthropic/claude-fable-5").supportsStreaming == false)
#expect(Model.openRouter(modelId: "fable5").supportsStreaming == false)
#expect(Model.openRouter(modelId: "anthropic/claude-opus-4-8").supportsStreaming == false)
#expect(Model.together(modelId: "claude-fable-5").supportsStreaming == false)
#expect(Model.openaiCompatible(
modelId: "anthropic/claude-fable-5",
baseURL: "https://example.test",
).supportsStreaming == false)
#expect(Model.openaiCompatible(
modelId: "fable5",
baseURL: "https://example.test",
).supportsStreaming == false)
#expect(Model.openaiCompatible(
modelId: "sonnet4-local",
baseURL: "https://example.test",
).supportsStreaming == true)
#expect(Model.grok(.grok43).supportsStreaming == true)
#expect(Model.ollama(.llama33).supportsStreaming == true)
}

View File

@ -2,6 +2,11 @@ import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
import Testing
@testable import Tachikoma
@ -18,8 +23,11 @@ struct OpenAIResponsesProviderTests {
let config = self.openAIConfig()
let gpt5Models: [LanguageModel.OpenAI] = [
.gpt52,
.gpt51,
.chatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5,
.gpt5Mini,
.gpt5Nano,
@ -39,8 +47,8 @@ struct OpenAIResponsesProviderTests {
}
@Test
func `GPT-5.1 text.verbosity parameter is set correctly`() throws {
// Test that the text.verbosity parameter is properly configured for GPT-5.1
func `GPT-5.5 text.verbosity parameter is set correctly`() throws {
// Test that the text.verbosity parameter is properly configured for GPT-5.5
let config = self.openAIConfig()
// Skip if no API key
@ -49,7 +57,7 @@ struct OpenAIResponsesProviderTests {
}
let provider = try OpenAIResponsesProvider(
model: .gpt51,
model: .gpt55,
configuration: config,
)
@ -64,26 +72,27 @@ struct OpenAIResponsesProviderTests {
// We can't directly test the internal request building without making it public
// But we can verify the provider is configured correctly
#expect(provider.modelId == "gpt-5.1")
#expect(provider.modelId == "gpt-5.5")
#expect(provider.capabilities.supportsTools == true)
#expect(provider.capabilities.supportsVision == true)
}
@Test
func `Reasoning models use Responses API`() throws {
// Test that reasoning-oriented models also use the OpenAIResponsesProvider
func `GPT-5 models use Responses API`() throws {
// Test that GPT-5 models use the OpenAIResponsesProvider
let config = self.openAIConfig()
let reasoningModels: [LanguageModel.OpenAI] = [
.o4Mini,
.gpt52,
.gpt51,
let responsesModels: [LanguageModel.OpenAI] = [
.chatLatest,
.gpt55,
.gpt54,
.gpt54Mini,
.gpt54Nano,
.gpt5,
.gpt5Mini,
.gpt5Thinking,
]
for model in reasoningModels {
for model in responsesModels {
let provider = try ProviderFactory.createProvider(
for: .openai(model),
configuration: config,
@ -97,11 +106,10 @@ struct OpenAIResponsesProviderTests {
}
@Test
func `Legacy models use standard OpenAI provider`() throws {
// Test that non-GPT-5/reasoning models use the standard OpenAIProvider
func `Custom OpenAI models use standard OpenAI provider`() throws {
let config = self.openAIConfig()
let legacyModels: [LanguageModel.OpenAI] = [.gpt4o, .gpt4oMini, .gpt41]
let legacyModels: [LanguageModel.OpenAI] = [.custom("custom-openai")]
for model in legacyModels {
let provider = try ProviderFactory.createProvider(
@ -111,7 +119,7 @@ struct OpenAIResponsesProviderTests {
#expect(
provider is OpenAIProvider,
"Legacy model \(model) should use OpenAIProvider",
"Custom model \(model) should use OpenAIProvider",
)
}
}
@ -196,6 +204,7 @@ struct OpenAIResponsesProviderTests {
choices: nil,
usage: nil,
metadata: nil,
incompleteDetails: nil,
)
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
@ -208,6 +217,159 @@ struct OpenAIResponsesProviderTests {
#expect(providerResponse.finishReason == .toolCalls)
}
@Test
func `GPT-5 incomplete content filter response maps finish reason`() throws {
let output = OpenAIResponsesResponse.ResponsesOutput(
id: "out_1",
type: "message",
status: "incomplete",
content: [
.init(type: "output_text", text: "blocked partial", toolCall: nil),
],
role: "assistant",
toolCall: nil,
)
let response = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: #require("""
{
"id": "resp_1",
"object": "response",
"created_at": 0,
"status": "incomplete",
"model": "gpt-5",
"output": [
{
"id": "out_1",
"type": "message",
"status": "incomplete",
"role": "assistant",
"content": [
{ "type": "output_text", "text": "blocked partial" }
]
}
],
"incomplete_details": { "reason": "content_filter" }
}
""".data(using: .utf8)))
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
#expect(output.status == "incomplete")
#expect(providerResponse.text.isEmpty)
#expect(providerResponse.finishReason == .contentFilter)
}
@Test
func `GPT-5 incomplete content filter discards parsed tool calls`() throws {
let toolCall = OpenAIResponsesResponse.ResponsesToolCall(
id: "call_1",
type: "function",
function: .init(name: "see", arguments: "{\"mode\":\"screen\"}"),
)
let output = OpenAIResponsesResponse.ResponsesOutput(
id: "out_1",
type: "message",
status: "incomplete",
content: [
.init(type: "output_text", text: "blocked partial", toolCall: nil),
.init(type: "tool_call", text: nil, toolCall: toolCall),
],
role: "assistant",
toolCall: nil,
)
let response = OpenAIResponsesResponse(
id: "resp_1",
object: "response",
createdAt: 0,
created: nil,
status: "incomplete",
model: "gpt-5",
output: [output],
choices: nil,
usage: nil,
metadata: nil,
incompleteDetails: .init(reason: "content_filter"),
)
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
#expect(providerResponse.text.isEmpty)
#expect(providerResponse.toolCalls == nil)
#expect(providerResponse.finishReason == .contentFilter)
}
@Test
func `GPT-5 completed refusal output maps to content filter`() throws {
let output = OpenAIResponsesResponse.ResponsesOutput(
id: "out_1",
type: "message",
status: "completed",
content: [
.init(type: "refusal", refusal: "I cannot help with that."),
],
role: "assistant",
toolCall: nil,
)
let response = OpenAIResponsesResponse(
id: "resp_1",
object: "response",
createdAt: 0,
created: nil,
status: "completed",
model: "gpt-5",
output: [output],
choices: nil,
usage: nil,
metadata: nil,
incompleteDetails: nil,
)
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
#expect(providerResponse.text.isEmpty)
#expect(providerResponse.toolCalls == nil)
#expect(providerResponse.finishReason == .contentFilter)
}
@Test
func `Alternate choices content filter suppresses text and tool calls`() throws {
let response = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: #require("""
{
"id": "chatcmpl_1",
"object": "chat.completion",
"created": 0,
"model": "gpt-5",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "blocked partial",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {
"name": "see",
"arguments": "{\\"mode\\":\\"screen\\"}"
}
}
]
},
"finish_reason": "content_filter",
"logprobs": null
}
]
}
""".data(using: .utf8)))
let providerResponse = try OpenAIResponsesProvider.convertToProviderResponse(response)
#expect(providerResponse.text.isEmpty)
#expect(providerResponse.toolCalls == nil)
#expect(providerResponse.finishReason == .contentFilter)
}
@Test
func `Responses provider hits /v1/responses and encodes body`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
@ -239,6 +401,67 @@ struct OpenAIResponsesProviderTests {
}
}
@Test
func `chat-latest Responses payload omits GPT-5 reasoning controls`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
let body = try #require(Self.bodyData(from: request))
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
#expect(json?["model"] as? String == "chat-latest")
#expect(json?["reasoning"] == nil)
#expect(json?["text"] == nil)
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "pong"))
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .chatLatest, configuration: config, session: session)
_ = try await provider.generateText(request: self.sampleRequest)
}
}
@Test
func `GPT-5 Chat Responses payload preserves model ID`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
let body = try #require(Self.bodyData(from: request))
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
#expect(json?["model"] as? String == "gpt-5-chat-latest")
#expect(json?["reasoning"] == nil)
#expect(json?["text"] == nil)
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "pong"))
} operation: { session in
let provider = try OpenAIResponsesProvider(
model: .gpt5ChatLatest,
configuration: config,
session: session,
)
#expect(provider.capabilities.contextLength == 128_000)
#expect(provider.capabilities.maxOutputTokens == 16384)
_ = try await provider.generateText(request: self.sampleRequest)
}
}
@Test
func `Responses provider resolves OAuth access token`() async throws {
try await self.withIsolatedAuthState {
try TKAuthManager.shared.setCredential(key: "OPENAI_ACCESS_TOKEN", value: "oauth-access-token")
let config = TachikomaConfiguration(loadFromEnvironment: false)
try await self.withMockedSession { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer oauth-access-token")
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "oauth ok"))
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt5Mini, configuration: config, session: session)
let response = try await provider.generateText(request: self.sampleRequest)
#expect(response.text.contains("oauth ok"))
}
}
}
@Test
func `Responses payload uses data URL string for images`() async throws {
let config = self.openAIConfig()
@ -450,27 +673,235 @@ struct OpenAIResponsesProviderTests {
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamChunkJSON(content: "Hello", finishReason: nil),
Self.streamChunkJSON(content: "Hello world", finishReason: nil),
Self.streamChunkJSON(content: " world", finishReason: nil),
Self.streamChunkJSON(content: nil, finishReason: "stop"),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .o4Mini, configuration: config, session: session)
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
var collected = ""
var receivedDone = false
for try await delta in stream {
switch delta.type {
case .textDelta:
collected.append(delta.content ?? "")
case .done:
break
receivedDone = true
case .toolCall, .toolResult, .reasoning:
break
}
}
#expect(collected == "Hello world")
#expect(receivedDone)
}
}
@Test
func `Responses provider marks completed tool streams as tool calls`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamEventJSON([
"type": "response.output_item.added",
"item": [
"id": "item_1",
"type": "function_call",
"name": "lookup",
],
]),
Self.streamEventJSON([
"type": "response.function_call_arguments.done",
"item_id": "item_1",
"arguments": #"{"query":"weather"}"#,
]),
Self.streamEventJSON(["type": "response.completed"]),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
var sawToolCall = false
var finishReason: FinishReason?
for try await delta in stream {
if delta.type == .toolCall {
sawToolCall = true
}
if delta.type == .done {
finishReason = delta.finishReason
}
}
#expect(sawToolCall)
#expect(finishReason == .toolCalls)
}
}
@Test
func `Responses provider maps incomplete content filter stream finish reason`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamChunkJSON(content: "partial", finishReason: nil),
Self.streamEventJSON([
"type": "response.incomplete",
"response": [
"incomplete_details": ["reason": "content_filter"],
],
]),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
var collected = ""
var finishReason: FinishReason?
for try await delta in stream {
if case .textDelta = delta.type {
collected.append(delta.content ?? "")
}
if delta.type == .done {
finishReason = delta.finishReason
}
}
#expect(collected == "partial")
#expect(finishReason == .contentFilter)
}
}
@Test
func `Responses provider maps refusal stream events to content filter`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamEventJSON([
"type": "response.refusal.delta",
"delta": "no",
]),
Self.streamEventJSON(["type": "response.refusal.done"]),
Self.streamEventJSON(["type": "response.completed"]),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
var finishReason: FinishReason?
for try await delta in stream where delta.type == .done {
finishReason = delta.finishReason
}
#expect(finishReason == .contentFilter)
}
}
@Test
func `Responses provider throws on failed stream event`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamEventJSON([
"type": "response.failed",
"response": [
"error": [
"message": "stream failed after partial output",
],
],
]),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
do {
for try await _ in stream {}
Issue.record("Expected stream failure")
} catch let error as TachikomaError {
guard case let .apiError(message) = error else {
Issue.record("Expected apiError, got \(error)")
return
}
#expect(message.contains("response.failed"))
#expect(message.contains("stream failed after partial output"))
}
}
}
@Test
func `Responses provider throws on error stream event`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamEventJSON([
"type": "error",
"message": "top-level stream error",
]),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .gpt55, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
do {
for try await _ in stream {}
Issue.record("Expected stream failure")
} catch let error as TachikomaError {
guard case let .apiError(message) = error else {
Issue.record("Expected apiError, got \(error)")
return
}
#expect(message.contains("error"))
#expect(message.contains("top-level stream error"))
}
}
}
@Test
func `chat-latest streams Responses event deltas`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setAPIKey("live-openai", for: .openai)
try await self.withMockedSession { request in
#expect(request.url?.path == "/v1/responses")
let payload = Self.responsesStreamPayload(chunks: [
Self.streamChunkJSON(content: "Hello", finishReason: nil),
Self.streamChunkJSON(content: " latest", finishReason: nil),
Self.streamChunkJSON(content: nil, finishReason: "stop"),
])
return NetworkMocking.streamResponse(for: request, data: payload)
} operation: { session in
let provider = try OpenAIResponsesProvider(model: .chatLatest, configuration: config, session: session)
let stream = try await provider.streamText(request: self.sampleRequest)
var collected = ""
for try await delta in stream {
if case .textDelta = delta.type {
collected.append(delta.content ?? "")
}
}
#expect(collected == "Hello latest")
}
}
@ -544,33 +975,27 @@ struct OpenAIResponsesProviderTests {
}
private static func streamChunkJSON(content: String?, finishReason: String?) -> String {
var delta: [String: Any] = [
"role": "assistant",
]
if let content {
delta["content"] = content
}
var choice: [String: Any] = [
"index": 0,
"delta": delta,
]
if let finishReason {
choice["finish_reason"] = finishReason
let chunk: [String: Any] = [
"type": "response.output_text.delta",
"delta": content,
]
let data = try! JSONSerialization.data(withJSONObject: chunk)
return String(data: data, encoding: .utf8)!
}
let chunk: [String: Any] = [
"id": "resp_stream",
"object": "response",
"created": 1_700_000_000,
"model": "o4-mini",
"choices": [choice],
"type": finishReason == nil ? "response.output_text.done" : "response.completed",
]
let data = try! JSONSerialization.data(withJSONObject: chunk)
return String(data: data, encoding: .utf8)!
}
private static func streamEventJSON(_ event: [String: Any]) -> String {
let data = try! JSONSerialization.data(withJSONObject: event)
return String(data: data, encoding: .utf8)!
}
private func withMockedSession<T>(
handler: @Sendable @escaping (URLRequest) throws -> (HTTPURLResponse, Data),
operation: (URLSession) async throws -> T,
@ -597,6 +1022,39 @@ struct OpenAIResponsesProviderTests {
enableMockOverride: false,
)
}
private func withIsolatedAuthState<T: Sendable>(
_ body: @Sendable () async throws -> T,
) async rethrows
-> T
{
try await TestEnvironmentMutex.shared.withLock {
let originalProfileDirectory = TachikomaConfiguration.profileDirectoryName
let profileDirectory = ".tachikoma-responses-auth-tests-\(UUID().uuidString)"
let profilePath = NSString(string: "~/" + profileDirectory).expandingTildeInPath
let previousIgnoreEnvironment = TKAuthManager.shared.setIgnoreEnvironment(false)
let previousIgnoreCredentialStore = TKAuthManager.shared.setIgnoreCredentialStore(false)
let savedOpenAIKey = getenv("OPENAI_API_KEY").map { String(cString: $0) }
TachikomaConfiguration.profileDirectoryName = profileDirectory
unsetenv("OPENAI_API_KEY")
try? FileManager.default.removeItem(atPath: profilePath)
defer {
if let savedOpenAIKey {
setenv("OPENAI_API_KEY", savedOpenAIKey, 1)
} else {
unsetenv("OPENAI_API_KEY")
}
TKAuthManager.shared.setIgnoreEnvironment(previousIgnoreEnvironment)
TKAuthManager.shared.setIgnoreCredentialStore(previousIgnoreCredentialStore)
TachikomaConfiguration.profileDirectoryName = originalProfileDirectory
try? FileManager.default.removeItem(atPath: profilePath)
}
return try await body()
}
}
}
private final class ResponsesTestURLProtocol: URLProtocol {

View File

@ -57,7 +57,7 @@ struct ProviderEndToEndTests {
let config = Self.makeConfiguration { config in
config.setAPIKey("sk-live-openai", for: .openai)
}
let provider = try OpenAIProvider(model: .gpt4o, configuration: config)
let provider = try OpenAIProvider(model: .gpt55, configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "OpenAI chat success")
}
@ -74,7 +74,7 @@ struct ProviderEndToEndTests {
let config = Self.makeConfiguration { config in
config.setAPIKey("live-anthropic", for: .anthropic)
}
let provider = try AnthropicProvider(model: .sonnet4, configuration: config)
let provider = try AnthropicProvider(model: .sonnet46, configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "Claude says hello")
}
@ -97,21 +97,141 @@ struct ProviderEndToEndTests {
}
}
@Test
func `Google provider encodes tool results as user function responses`() async throws {
let toolCall = AgentToolCall(
id: "call_weather",
name: "get_weather",
arguments: ["location": AnyAgentToolValue(string: "Vienna")],
)
let toolResult = AgentToolResult.success(
toolCallId: "call_weather",
result: AnyAgentToolValue(object: ["temperature": AnyAgentToolValue(int: 21)]),
)
let providerRequest = ProviderRequest(
messages: [
.user("Weather?"),
ModelMessage(role: .assistant, content: [.text("Checking."), .toolCall(toolCall)]),
ModelMessage(role: .tool, content: [.toolResult(toolResult)]),
],
settings: .init(maxTokens: 32),
)
try await NetworkMocking.withMockedNetwork { request in
let body = try #require(self.bodyData(from: request))
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
let contents = try #require(json["contents"] as? [[String: Any]])
#expect(contents.count == 3)
#expect(contents.compactMap { $0["role"] as? String } == ["user", "model", "user"])
let modelParts = try #require(contents[1]["parts"] as? [[String: Any]])
#expect(modelParts.count == 2)
#expect(modelParts[1]["functionCall"] != nil)
let toolParts = try #require(contents[2]["parts"] as? [[String: Any]])
let functionResponse = try #require(toolParts.first?["functionResponse"] as? [String: Any])
#expect(functionResponse["id"] as? String == "call_weather")
#expect(functionResponse["name"] as? String == "get_weather")
return NetworkMocking.streamResponse(for: request, data: Self.googleStreamPayload(text: "Done"))
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("google-live", for: .google)
}
let provider = try GoogleProvider(model: .gemini25Flash, configuration: config)
let response = try await provider.generateText(request: providerRequest)
#expect(response.text.contains("Done"))
}
}
@Test
func `Google provider drops orphan required tool parameters`() async throws {
let tool = AgentTool(
name: "search",
description: "Search files",
parameters: AgentToolParameters(
properties: [
"query": AgentToolParameterProperty(
name: "query",
type: .string,
description: "Search query",
),
],
required: ["query", "mode"],
),
) { _ in
AnyAgentToolValue(string: "ok")
}
let orphanOnlyTool = AgentTool(
name: "noop",
description: "No-op",
parameters: AgentToolParameters(
properties: [
"reason": AgentToolParameterProperty(
name: "reason",
type: .string,
description: "Reason",
),
],
required: ["missing"],
),
) { _ in
AnyAgentToolValue(string: "ok")
}
let providerRequest = ProviderRequest(
messages: [ModelMessage(role: .user, content: [.text("Find it")])],
tools: [tool, orphanOnlyTool],
settings: .init(maxTokens: 32),
)
try await NetworkMocking.withMockedNetwork { request in
let body = try #require(self.bodyData(from: request))
let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any])
let tools = try #require(json["tools"] as? [[String: Any]])
let declarations = try #require(tools.first?["functionDeclarations"] as? [[String: Any]])
var parametersByName: [String: [String: Any]] = [:]
for declaration in declarations {
let name = try #require(declaration["name"] as? String)
let parameters = try #require(declaration["parameters"] as? [String: Any])
#expect(!parametersByName.keys.contains(name))
parametersByName[name] = parameters
}
let searchParameters = try #require(parametersByName["search"])
let noopParameters = try #require(parametersByName["noop"])
#expect(searchParameters["properties"] is [String: Any])
#expect(searchParameters["required"] as? [String] == ["query"])
#expect(noopParameters["properties"] is [String: Any])
#expect(noopParameters["required"] == nil)
return NetworkMocking.streamResponse(for: request, data: Self.googleStreamPayload(text: "Done"))
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("google-live", for: .google)
}
let provider = try GoogleProvider(model: .gemini25Flash, configuration: config)
let response = try await provider.generateText(request: providerRequest)
#expect(response.text.contains("Done"))
}
}
// MARK: - OpenAI-compatible providers
@Test
func `Mistral provider uses OpenAI-compatible flow`() async throws {
try await self.assertOpenAICompatibleProvider(.mistral(.small), provider: .mistral)
try await self.assertOpenAICompatibleProvider(.mistral(.smallLatest), provider: .mistral)
}
@Test
func `Groq provider uses OpenAI-compatible flow`() async throws {
try await self.assertOpenAICompatibleProvider(.groq(.llama38b), provider: .groq)
try await self.assertOpenAICompatibleProvider(.groq(.llama318b), provider: .groq)
}
@Test
func `Grok provider uses OpenAI-compatible flow`() async throws {
try await self.assertOpenAICompatibleProvider(.grok(.grok4FastReasoning), provider: .grok)
try await self.assertOpenAICompatibleProvider(.grok(.grok43), provider: .grok)
}
@Test
@ -204,6 +324,7 @@ struct ProviderEndToEndTests {
try await NetworkMocking.withMockedNetwork { request in
self.expectPath(request, endsWith: "/chat/completions")
#expect(request.value(forHTTPHeaderField: "HTTP-Referer") == "https://peekaboo.app")
#expect(request.value(forHTTPHeaderField: "X-OpenRouter-Title") == "Peekaboo")
return NetworkMocking.jsonResponse(for: request, data: Self.chatCompletionPayload(text: "OpenRouter reply"))
} operation: {
let config = Self.makeConfiguration { config in
@ -292,6 +413,110 @@ struct ProviderEndToEndTests {
}
}
@Test
func `Anthropic-compatible provider accepts auth override`() async throws {
try await NetworkMocking.withMockedNetwork { request in
self.expectPath(request, endsWith: "/messages")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer compat-token")
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "Compat bearer"))
} operation: {
let provider = try AnthropicCompatibleProvider(
modelId: "claude-compat-4",
baseURL: "https://compat.anthropic.test",
configuration: Self.makeConfiguration { _ in },
auth: .bearer("compat-token", betaHeader: nil),
)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "Compat bearer")
}
}
@Test
func `MiniMax provider uses bearer auth`() async throws {
try await NetworkMocking.withMockedNetwork { request in
self.expectPath(request, endsWith: "/messages")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax")
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "MiniMax ok"))
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("live-minimax", for: .minimax)
}
let provider = try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "MiniMax ok")
}
}
@Test
func `MiniMax reasoning metadata is bound to configured endpoint`() async throws {
let baseURL = "https://minimax-proxy.test/anthropic?tenant=a"
try await NetworkMocking.withMockedNetwork { request in
#expect(request.url?.host == "minimax-proxy.test")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax")
return NetworkMocking.jsonResponse(
for: request,
data: Self.anthropicPayloadWithThinking(
text: "MiniMax ok",
thinking: "native-thought",
signature: "sig-mm",
),
)
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("live-minimax", for: .minimax)
config.setBaseURL(baseURL, for: .minimax)
}
let provider = try ProviderFactory.createProvider(for: .minimax(.m27), configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
let thinkingMessage = try #require(response.assistantMessages.first { $0.channel == .thinking })
let metadata = try #require(thinkingMessage.metadata?.customData)
#expect(metadata["tachikoma.reasoning.provider"] == "minimax")
#expect(metadata["tachikoma.reasoning.model"] == "MiniMax-M2.7")
#expect(metadata["anthropic.thinking.signature"] == "sig-mm")
#expect(metadata["tachikoma.reasoning.base_url"] == ReasoningEndpointIdentity.canonical(baseURL))
}
}
@Test
func `MiniMax China provider uses China endpoint and bearer auth`() async throws {
try await NetworkMocking.withMockedNetwork { request in
#expect(request.url?.host == "api.minimaxi.com")
self.expectPath(request, endsWith: "/messages")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer live-minimax-cn")
#expect(request.value(forHTTPHeaderField: "x-api-key") == nil)
return NetworkMocking.jsonResponse(for: request, data: Self.anthropicPayload(text: "MiniMax China ok"))
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("live-minimax-cn", for: .minimaxCN)
}
let provider = try ProviderFactory.createProvider(for: .minimaxCN(.m27), configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "MiniMax China ok")
}
}
@Test
func `MiniMax China provider falls back to MiniMax API key`() async throws {
try await NetworkMocking.withMockedNetwork { request in
#expect(request.url?.host == "api.minimaxi.com")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer shared-minimax")
return NetworkMocking.jsonResponse(
for: request,
data: Self.anthropicPayload(text: "MiniMax China fallback ok"),
)
} operation: {
let config = Self.makeConfiguration { config in
config.setAPIKey("shared-minimax", for: .minimax)
}
let provider = try ProviderFactory.createProvider(for: .minimaxCN(.m27), configuration: config)
let response = try await provider.generateText(request: Self.basicRequest)
#expect(response.text == "MiniMax China fallback ok")
}
}
// MARK: - Helpers
private func assertOpenAICompatibleProvider(_ model: LanguageModel, provider: Provider) async throws {
@ -362,7 +587,7 @@ struct ProviderEndToEndTests {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1_723_000_000,
"model": "gpt-4o",
"model": "gpt-5.5",
"choices": [
[
"index": 0,
@ -387,7 +612,26 @@ struct ProviderEndToEndTests {
"content": [
["type": "text", "text": text],
],
"model": "claude-sonnet-4-20250514",
"model": "claude-sonnet-4-6",
"stop_reason": "end_turn",
"usage": [
"input_tokens": 12,
"output_tokens": 6,
],
]
return try! JSONSerialization.data(withJSONObject: dict)
}
private static func anthropicPayloadWithThinking(text: String, thinking: String, signature: String) -> Data {
let dict: [String: Any] = [
"id": "msg_1",
"type": "message",
"role": "assistant",
"content": [
["type": "thinking", "thinking": thinking, "signature": signature],
["type": "text", "text": text],
],
"model": "MiniMax-M2.7",
"stop_reason": "end_turn",
"usage": [
"input_tokens": 12,

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