Compare commits

...

60 Commits
v0.1.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
Peter Steinberger
82c7aa095f
chore: release 0.2.0 2026-04-28 01:54:45 +01:00
Peter Steinberger
05b0052f3c
build: update swift dependencies and fix license 2026-04-27 11:29:07 +01:00
Peter Steinberger
1c33c03f2f
fix: stabilize concurrent audio batch tests 2026-04-27 08:53:54 +01:00
Peter Steinberger
90bb950a27
test: update MCP content patterns 2026-04-27 08:46:33 +01:00
Peter Steinberger
814e15f4fe fix: support swift-sdk 0.12 MCP content 2026-03-28 04:11:24 +00:00
Peter Steinberger
46a5405d40
style: run swiftformat 2026-03-13 20:19:11 +00:00
Peter Steinberger
8465b4e598
test(auth): isolate shared auth state 2026-03-13 20:19:04 +00:00
Peter Steinberger
02b70ea80a
fix(test): stabilize provider env resolution 2026-03-13 19:49:51 +00:00
Peter Steinberger
983e5bbf69
docs: note grouped git ops 2026-03-13 18:38:14 +00:00
Peter Steinberger
718f16edc4
docs: note test stabilization and MCP bridge updates 2026-03-13 13:35:20 +00:00
Peter Steinberger
efbedbc1ae
test: stabilize Tachikoma MCP and provider suites 2026-03-13 13:28:33 +00:00
Peter Steinberger
1655b8c549
fix(auth): align provider env aliases 2026-03-13 13:10:20 +00:00
Peter Steinberger
38f243db5f ci: drop parallel swift test 2026-01-18 08:09:58 +00:00
162 changed files with 12409 additions and 3550 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 --parallel $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

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

View File

@ -2,43 +2,58 @@
All notable changes to the Tachikoma project will be documented in this file.
## [0.1.0] - 2026-01-18
## [Unreleased]
### Added
- Core Swift 6 AI SDK with strict concurrency, streaming responses, and typed tool calling.
- Unified message/content model (text, images, audio) with structured tool results.
- Provider support for OpenAI (Chat + Responses), Anthropic, xAI (Grok), Google Gemini, Ollama, and OpenAI-compatible endpoints (OpenRouter/Together/Replicate).
- Config system with credential store + env overrides, model registry, and capability lookup helpers.
- Test helpers and mock infrastructure for deterministic provider/unit coverage.
- 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.
All notable changes to the Tachikoma project will be documented in this file.
### 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`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### 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.
## [Unreleased]
## [0.2.0] - 2026-04-28
### Added
- First-class Azure OpenAI provider: deployment-based model case `.azureOpenAI`, Azure-specific URL/header/query wiring (api-version, api-key or bearer token), env overrides (`AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_BEARER_TOKEN`, `AZURE_OPENAI_ENDPOINT`/`RESOURCE`, `AZURE_OPENAI_API_VERSION`), and README guidance.
- Azure provider unit tests using URLProtocol stubs to verify path, query, and auth header construction.
### Changed
- Added OpenAI's GPT-5.1 family (flagship/mini/nano) throughout the model enums, selectors, provider factories, capability registry, pricing tables, docs, and test suites. GPT aliases (`gpt`, `gpt-5`, `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.
- `TachikomaConfiguration` can optionally override the provider factory so test harnesses can inject mock providers without affecting production defaults, improving hermetic test runs.
- Implemented OpenRouter, Together, Replicate, and Anthropic-compatible providers on top of the shared helpers so aggregator models no longer throw “not yet implemented” errors and honour custom base URLs/headers.
- `Provider.environmentValue` falls back to classic `getenv` lookups when the modern configuration reader returns no value, ensuring environment overrides succeed on macOS 14 deployments.
- Provider environment reads now use direct process environment lookups, so test overrides and runtime unsets behave deterministically across SwiftPM/Xcode runs.
### Fixed
- MCP bridge conversions now handle embedded resources and resource links from `swift-sdk` 0.11, and test helpers no longer swap mock keys for live environment credentials during provider/audio suites.
- `retryWithCancellation` now registers token handlers per-attempt and cancels in-flight work, resolving hangs when external cancellation should short-circuit retries.
- Audio provider tests and helpers consistently force mock mode when exercising stub audio payloads, preventing accidental live API calls that fail to decode fixtures.
- `TestHelpers` expose discardable configuration helpers and stricter mock-key detection, reducing compiler warnings and flaky skips.
- OpenAI transcription timestamp tests no longer hit the live API and succeed reliably under both mock and real key configurations.
- Google provider API-key resolution no longer treats `GOOGLE_APPLICATION_CREDENTIALS` file paths as credential strings.
- Anthropic OAuth login token exchange now uses the correct request format (JSON body + `state`). Thanks @jonathanglasmeyer.
### Testing
@ -48,13 +63,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added provider-level network E2E coverage using local `URLProtocol` stubs plus new OpenAI Responses API tests (request encoding + streaming) so critical serialization paths are exercised without live traffic.
- `ProviderEndToEndTests` now exercise every provider flavor (OpenRouter/Together/Replicate, OpenAI/Anthropic compatible, etc.), pushing overall line coverage above 40% while keeping the suite deterministic via URLProtocol stubs.
### Planned Features
- Enhanced caching with persistence and TTL
- Bidirectional streaming support
- Request batching for high-volume usage
- Advanced error recovery mechanisms
- Metrics collection and monitoring
- Distributed caching support
## [0.1.0] - 2026-01-18
### Added
- Core Swift 6 AI SDK with strict concurrency, streaming responses, and typed tool calling.
- Unified message/content model (text, images, audio) with structured tool results.
- Provider support for OpenAI (Chat + Responses), Anthropic, xAI (Grok), Google Gemini, Ollama, and OpenAI-compatible endpoints (OpenRouter/Together/Replicate).
- Config system with credential store + env overrides, model registry, and capability lookup helpers.
- Test helpers and mock infrastructure for deterministic provider/unit coverage.
## [1.0.0] - 2025-01-XX
@ -71,8 +87,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
#### 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
@ -80,7 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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
@ -209,4 +225,4 @@ This is the initial release, so no breaking changes from previous versions.
## License
This project is licensed under the MIT License. See LICENSE file for details.
This project is licensed under the MIT License. See LICENSE file for details.

View File

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

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

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

View File

@ -49,7 +49,7 @@ echo " • Stream token handling"
echo ""
echo "🔑 API Integration Examples (require valid API keys):"
echo " • OpenAI GPT-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)")
@ -106,7 +106,7 @@ func testRealtimeConfiguration() async throws {
print(" swift run RealtimeVoiceAssistant --basic")
}
// Extension for string multiplication
/// Extension for string multiplication
extension String {
static func * (string: String, count: Int) -> String {
String(repeating: string, count: count)

View File

@ -24,7 +24,7 @@ class BasicVoiceAssistant: ObservableObject {
// Start conversation with voice
try await self.conversation?.start(
model: .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,6 @@
MIT License
\g<1>2026 Peter Steinberger
Copyright (c) 2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,13 +1,22 @@
{
"originHash" : "71bb7313f33f89ee4c3627d91346c2371cad8918a22c27c75a7b66f6ebde75ef",
"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",
"location" : "https://github.com/mattt/eventsource.git",
"state" : {
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
"version" : "1.3.0"
"revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
"version" : "1.4.1"
}
},
{
@ -24,8 +33,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
"version" : "1.5.1"
"revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008",
"version" : "1.7.1"
}
},
{
@ -33,17 +42,26 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440",
"version" : "1.1.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"location" : "https://github.com/apple/swift-collections",
"state" : {
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
"version" : "1.3.0"
"revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a",
"version" : "1.6.0"
}
},
{
@ -51,8 +69,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
"version" : "1.0.0"
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
}
},
{
@ -60,8 +78,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
"version" : "4.2.0"
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
}
},
{
@ -69,8 +87,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca",
"version" : "1.8.0"
"revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a",
"version" : "1.13.2"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "a8e036cb8628fcc1ff67dfec6ce8168617172c9b",
"version" : "2.101.1"
}
},
{
@ -87,8 +114,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
"state" : {
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
"version" : "0.10.2"
"revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605",
"version" : "0.12.1"
}
},
{
@ -96,8 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle",
"state" : {
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
"version" : "2.9.1"
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a",
"version" : "2.11.0"
}
},
{
@ -105,8 +132,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
"version" : "1.6.3"
"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,9 +59,9 @@ 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.10.2"),
.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"),
.package(url: "https://github.com/apple/swift-algorithms", from: "1.2.1"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "4.2.0"),

View File

@ -10,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)")
}
}
@ -485,7 +525,6 @@ extension ProviderFactory {
-> any ModelProvider
{
// Create a provider with configuration
let provider = try createProvider(for: model, configuration: configuration)
return provider
try createProvider(for: model, configuration: configuration)
}
}

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

View File

@ -113,10 +113,10 @@ public enum EmbeddingModel: Sendable {
// Convert to LanguageModel for usage tracking
switch self {
case .openai:
.openai(.gpt4o) // Placeholder for tracking
.openai(.gpt55) // Placeholder for tracking
case .cohere, .voyage, .custom:
// Return a dummy model for tracking purposes
.openai(.gpt4o)
.openai(.gpt55)
}
}
}
@ -220,7 +220,7 @@ protocol EmbeddingProvider: Sendable {
/// Request for embedding generation
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
struct EmbeddingRequest: Sendable {
struct EmbeddingRequest {
let input: EmbeddingInput
let settings: EmbeddingSettings
}

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

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

View File

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

View File

@ -56,7 +56,7 @@ public struct OpenAIOptions: Sendable, Codable {
/// Verbosity level for GPT-5 models
public var verbosity: Verbosity?
/// Reasoning effort for 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

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

View File

@ -186,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
}
@ -246,7 +247,7 @@ public struct ModelMessage: Sendable, Codable, Equatable {
self.metadata = metadata
}
// Convenience initializers
/// Convenience initializers
public static func system(_ text: String) -> ModelMessage {
ModelMessage(role: .system, content: [.text(text)])
}
@ -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,12 +369,30 @@ public struct GenerationSettings: Sendable {
self.stopConditions = stopConditions
self.seed = seed
self.providerOptions = providerOptions
self.streamBuffering = streamBuffering
}
public static let `default` = GenerationSettings()
public func withStreamBuffering(_ mode: StreamBufferingMode) -> GenerationSettings {
GenerationSettings(
maxTokens: self.maxTokens,
temperature: self.temperature,
topP: self.topP,
topK: self.topK,
frequencyPenalty: self.frequencyPenalty,
presencePenalty: self.presencePenalty,
stopSequences: self.stopSequences,
reasoningEffort: self.reasoningEffort,
stopConditions: self.stopConditions,
seed: self.seed,
providerOptions: self.providerOptions,
streamBuffering: mode,
)
}
}
// Manual Codable conformance excluding non-codable stopConditions
/// Manual Codable conformance excluding non-codable stopConditions
extension GenerationSettings: Codable {
enum CodingKeys: String, CodingKey {
case maxTokens
@ -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
}
}
@ -508,7 +538,7 @@ public struct TextStreamDelta: Sendable {
self.finishReason = finishReason
}
// Convenience constructors
/// Convenience constructors
public static func text(_ content: String, channel: ResponseChannel? = nil) -> TextStreamDelta {
TextStreamDelta(type: .textDelta, content: content, channel: channel)
}
@ -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, *)

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@ -1,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

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

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

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

View File

@ -20,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

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

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

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

View File

@ -6,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,17 +629,39 @@ extension ResponseCache {
public struct CacheAwareProvider<Base: ModelProvider>: ModelProvider {
let provider: Base
let cache: ResponseCache
private let providerIdentity: CacheProviderIdentity
public var modelId: String { self.provider.modelId }
public var baseURL: String? { self.provider.baseURL }
public var apiKey: String? { self.provider.apiKey }
public var capabilities: ModelCapabilities { self.provider.capabilities }
public var modelId: String {
self.provider.modelId
}
public var baseURL: String? {
self.provider.baseURL
}
public var apiKey: String? {
self.provider.apiKey
}
public var capabilities: ModelCapabilities {
self.provider.capabilities
}
init(provider: Base, cache: ResponseCache) {
self.provider = provider
self.cache = cache
self.providerIdentity = CacheProviderIdentity(
providerKind: String(reflecting: Base.self),
modelId: provider.modelId,
baseURL: provider.baseURL,
)
}
public 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
}
@ -564,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

@ -267,7 +267,7 @@ public struct UsageOperation: Sendable, Codable {
self.type = type
}
// For backward compatibility, provide a computed property to reconstruct model info
/// For backward compatibility, provide a computed property to reconstruct model info
public var modelDescription: String {
"\(self.providerName)/\(self.modelId)"
}
@ -393,10 +393,21 @@ public struct UsageReport: Sendable {
public let endDate: Date
public let sessions: [UsageSession]
public var totalSessions: Int { self.sessions.count }
public var totalOperations: Int { self.sessions.reduce(0) { $0 + $1.operations.count } }
public var totalTokens: Int { self.sessions.reduce(0) { $0 + $1.totalTokens } }
public var totalCost: Double { self.sessions.reduce(0) { $0 + $1.totalCost } }
public var totalSessions: Int {
self.sessions.count
}
public var totalOperations: Int {
self.sessions.reduce(0) { $0 + $1.operations.count }
}
public var totalTokens: Int {
self.sessions.reduce(0) { $0 + $1.totalTokens }
}
public var totalCost: Double {
self.sessions.reduce(0) { $0 + $1.totalCost }
}
public let providerBreakdown: [String: ProviderUsage]
public let modelBreakdown: [String: ModelUsage]
@ -517,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)
@ -558,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)
let trackedStream = AsyncThrowingStream<TextStreamDelta, Error> { continuation in
Task {
return AsyncThrowingStream<TextStreamDelta, Error> { continuation in
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,27 +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()
}
}
return trackedStream
}
/// 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)
}
@ -171,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
@ -181,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 {
@ -539,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
let processedStream = AsyncThrowingStream<String, Error> { continuation in
Task {
return AsyncThrowingStream<String, Error> { continuation in
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,9 +414,17 @@ public final class Conversation: @unchecked Sendable {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { @Sendable _ in
producer.cancel()
}
}
}
}
return processedStream
extension [ModelMessage] {
func droppingLastUserTurn() -> [ModelMessage] {
guard self.last?.role == .user else { return self }
return Array(self.dropLast())
}
}
@ -163,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
@ -171,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
@ -191,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,
)
}
@ -221,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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -128,7 +128,7 @@ public struct InputAudioTranscription: Sendable, Codable {
/// Session configuration with all options
@available(macOS 13.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *)
public struct SessionConfiguration: Sendable, Codable {
/// Model to use (e.g., "gpt-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

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

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

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

View File

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

View File

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

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

View File

@ -116,7 +116,7 @@ struct TKConfigCLI {
var mutable = raw
let timeout = self.parseTimeout(&mutable)
print("Providers:")
for pid in [TKProviderId.openai, .anthropic, .grok, .gemini] {
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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