Compare commits
2 Commits
main
...
feat/opena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5cde9fac1 | ||
|
|
4538eddfac |
@ -5,6 +5,7 @@ All notable changes to the Tachikoma project will be documented in this file.
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Added first-class OpenAI `chat-latest` support with parsing aliases, Responses API routing, model capabilities, and usage estimates.
|
||||
- Added first-class MiniMax support with the `MiniMax-M2.7` catalog models, `MINIMAX_API_KEY` / `MINIMAX_BASE_URL` configuration, bearer-token Anthropic-compatible transport, model parsing shortcuts, usage estimates, and provider tests.
|
||||
- Added explicit LM Studio model shortcuts such as `lmstudio` and `lmstudio/openai/gpt-oss-120b` so local provider selections no longer fall through to Ollama custom IDs.
|
||||
|
||||
@ -13,6 +14,7 @@ All notable changes to the Tachikoma project will be documented in this file.
|
||||
- Removed stale direct model support for retired or non-canonical IDs including GPT-5.1/5.2/pseudo-thinking models, deprecated Claude Sonnet/Opus 4 snapshots, Grok 2/3/4-fast rows, old Groq Llama/Mixtral/Gemma aliases, stale Mistral aliases, and invalid LM Studio `current`.
|
||||
|
||||
### Fixed
|
||||
- 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.
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
{
|
||||
"originHash" : "12a454cd38a6ae2519d652cc0872f7f18feb64690ce83d1507bae6db71c1841c",
|
||||
"originHash" : "46472c9ff88e15d0aa5ee2b3eeaee7e4b8f12474173a0367b7922efa6c48a62b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander",
|
||||
"state" : {
|
||||
"revision" : "ae2ce746b386ff94b26648cfe5625cfa8d02639b",
|
||||
"version" : "0.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
// 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 commanderDependency: Package.Dependency = .package(url: "https://github.com/steipete/Commander", from: "0.2.2")
|
||||
|
||||
let package = Package(
|
||||
name: "Tachikoma",
|
||||
|
||||
@ -241,6 +241,7 @@ public final class ModelCapabilityRegistry: @unchecked Sendable {
|
||||
excludedParameters: ["temperature", "topP", "frequencyPenalty", "presencePenalty"],
|
||||
)
|
||||
|
||||
self.capabilities["openai: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
|
||||
|
||||
@ -186,7 +186,7 @@ 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 .gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt54, .gpt54Mini, .gpt54Nano, .gpt55:
|
||||
case .chatLatest, .gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano, .gpt54, .gpt54Mini, .gpt54Nano, .gpt55:
|
||||
.responses // GPT-5 defaults to Responses API
|
||||
default:
|
||||
.chat // All other models use Chat Completions API
|
||||
|
||||
@ -33,6 +33,9 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
// MARK: - Provider Sub-Enums
|
||||
|
||||
public enum OpenAI: Sendable, Hashable, CaseIterable {
|
||||
/// Latest ChatGPT Instant alias.
|
||||
case chatLatest
|
||||
|
||||
/// GPT-5.5 Series
|
||||
case gpt55 // Flagship GPT-5.5
|
||||
|
||||
@ -52,6 +55,7 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
|
||||
public static var allCases: [OpenAI] {
|
||||
[
|
||||
.chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
@ -66,6 +70,7 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
public var modelId: String {
|
||||
switch self {
|
||||
case let .custom(id): id
|
||||
case .chatLatest: "chat-latest"
|
||||
case .gpt55: "gpt-5.5"
|
||||
case .gpt54: "gpt-5.4"
|
||||
case .gpt54Mini: "gpt-5.4-mini"
|
||||
@ -79,7 +84,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
|
||||
public var supportsVision: Bool {
|
||||
switch self {
|
||||
case .gpt55,
|
||||
case .chatLatest,
|
||||
.gpt55,
|
||||
.gpt54, .gpt54Mini, .gpt54Nano,
|
||||
.gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano: true // GPT-5+ supports multimodal
|
||||
default: false
|
||||
@ -88,7 +94,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
|
||||
public var supportsTools: Bool {
|
||||
switch self {
|
||||
case .gpt55,
|
||||
case .chatLatest,
|
||||
.gpt55,
|
||||
.gpt54, .gpt54Mini, .gpt54Nano,
|
||||
.gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano: true // GPT-5+ excels at tool calling
|
||||
case .custom: true // Assume custom models support tools
|
||||
@ -120,7 +127,8 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
|
||||
public var contextLength: Int {
|
||||
switch self {
|
||||
case .gpt55,
|
||||
case .chatLatest,
|
||||
.gpt55,
|
||||
.gpt54, .gpt54Mini, .gpt54Nano,
|
||||
.gpt5, .gpt5Pro, .gpt5Mini, .gpt5Nano: 400_000 // 272k input + 128k output
|
||||
case .custom: 128_000 // Default assumption
|
||||
@ -136,8 +144,7 @@ public enum LanguageModel: Sendable, CustomStringConvertible, Hashable {
|
||||
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") ||
|
||||
normalized == "gpt-5-chat-latest" || compact == "gpt5chatlatest"
|
||||
normalized.contains("gpt-5-thinking") || compact.contains("gpt5thinking")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1114,6 +1121,13 @@ extension LanguageModel {
|
||||
|
||||
if let qualified = ProviderParser.parse(trimmed) {
|
||||
let provider = qualified.provider.lowercased()
|
||||
if provider == "openai" {
|
||||
guard
|
||||
let parsed = Self.parse(from: qualified.model),
|
||||
case .openai = parsed else { return nil }
|
||||
|
||||
return parsed
|
||||
}
|
||||
if provider == "openrouter" {
|
||||
return .openRouter(modelId: qualified.model)
|
||||
}
|
||||
@ -1132,11 +1146,18 @@ extension LanguageModel {
|
||||
if
|
||||
compact.contains("gpt4") || compact.contains("gpt3") || compact.contains("o3") || compact.contains("o4") ||
|
||||
compact.contains("gpt51") || compact.contains("gpt52") ||
|
||||
compact.contains("gpt5thinking") || compact.contains("gpt5chat")
|
||||
compact.contains("gpt5thinking")
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
if
|
||||
dashed == "chat-latest" || compact == "chatlatest" || dashed == "gpt-5-chat-latest" || compact ==
|
||||
"gpt5chatlatest"
|
||||
{
|
||||
return .openai(.chatLatest)
|
||||
}
|
||||
|
||||
if dashed == "gpt-5-pro" || compact == "gpt5pro" {
|
||||
return .openai(.gpt5Pro)
|
||||
}
|
||||
|
||||
@ -59,6 +59,9 @@ public struct ModelSelector {
|
||||
|
||||
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)
|
||||
}
|
||||
@ -98,6 +101,8 @@ public struct ModelSelector {
|
||||
|
||||
private static func parseOpenAIModel(_ input: String) -> Model.OpenAI? {
|
||||
switch input {
|
||||
case "chat-latest", "chatlatest", "gpt-5-chat-latest", "gpt5-chat-latest", "gpt5chatlatest":
|
||||
return .chatLatest
|
||||
// GPT-5.5 models
|
||||
case "gpt-5.5", "gpt5.5", "gpt-5-5", "gpt5-5", "gpt55":
|
||||
return .gpt55
|
||||
@ -180,8 +185,7 @@ public struct ModelSelector {
|
||||
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") ||
|
||||
normalized == "gpt-5-chat-latest" || compact == "gpt5chatlatest"
|
||||
normalized.contains("gpt-5-thinking") || compact.contains("gpt5thinking")
|
||||
}
|
||||
|
||||
private static func isUnsupportedLegacyAnthropicModel(_ input: String) -> Bool {
|
||||
|
||||
@ -58,13 +58,14 @@ 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
|
||||
|
||||
self.capabilities = ModelCapabilities(
|
||||
supportsVision: model.supportsVision,
|
||||
supportsTools: model.supportsTools,
|
||||
supportsStreaming: true,
|
||||
contextLength: model.contextLength,
|
||||
maxOutputTokens: isReasoningModel || isGPT5 ? 128_000 : 4096,
|
||||
maxOutputTokens: hasLargeOutputWindow ? 128_000 : 4096,
|
||||
)
|
||||
}
|
||||
|
||||
@ -263,8 +264,8 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
}
|
||||
|
||||
if let data = jsonString.data(using: .utf8) {
|
||||
// Try GPT-5 format first
|
||||
if Self.isGPT5Model(self.model) {
|
||||
// Responses API event streams use typed event payloads.
|
||||
if Self.usesResponsesEventStream(self.model) {
|
||||
if
|
||||
let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let eventType = event["type"] as? String
|
||||
@ -939,6 +940,10 @@ public final class OpenAIResponsesProvider: ModelProvider {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private static func usesResponsesEventStream(_ model: LanguageModel.OpenAI) -> Bool {
|
||||
model == .chatLatest || self.isGPT5Model(model)
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration extensions removed - properties are immutable for Sendable conformance
|
||||
|
||||
@ -17,7 +17,8 @@ public struct ProviderFactory {
|
||||
case let .openai(openaiModel):
|
||||
// Use Responses API for the GPT-5 family
|
||||
switch openaiModel {
|
||||
case .gpt55,
|
||||
case .chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
.gpt54Nano,
|
||||
|
||||
@ -227,13 +227,14 @@ public enum ProviderParser {
|
||||
!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"),
|
||||
normalized != "gpt-5-chat-latest", compact != "gpt5chatlatest" else
|
||||
!normalized.contains("gpt-5-thinking"), !compact.contains("gpt5thinking") else
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
return switch normalized {
|
||||
case "chat-latest", "chatlatest", "gpt-5-chat-latest", "gpt5-chat-latest", "gpt5chatlatest":
|
||||
.openai(.chatLatest)
|
||||
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)
|
||||
|
||||
@ -528,6 +528,7 @@ public struct ModelCostCalculator: Sendable {
|
||||
// OpenAI Pricing (as of 2025)
|
||||
case let .openai(openaiModel):
|
||||
switch openaiModel {
|
||||
case .chatLatest: (5.00, 30.00) // ChatGPT Instant alias pricing estimate
|
||||
case .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)
|
||||
|
||||
@ -28,6 +28,13 @@ enum ModelCapabilitiesTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat-latest does not advertise audio input`() {
|
||||
#expect(LanguageModel.openai(.chatLatest).supportsVision)
|
||||
#expect(LanguageModel.openai(.chatLatest).supportsTools)
|
||||
#expect(LanguageModel.openai(.chatLatest).supportsAudioInput == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Gemini 3 Flash supports thinking config options`() {
|
||||
let capabilities = ModelCapabilityRegistry.shared.capabilities(for: .google(.gemini3Flash))
|
||||
|
||||
@ -14,6 +14,15 @@ struct ModelParsingTests {
|
||||
#expect(parsed == .openai(.gpt55))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse chat latest OpenAI alias`() throws {
|
||||
#expect(LanguageModel.parse(from: "chat-latest") == .openai(.chatLatest))
|
||||
#expect(LanguageModel.parse(from: "gpt-5-chat-latest") == .openai(.chatLatest))
|
||||
#expect(LanguageModel.parse(from: "openai/chat-latest") == .openai(.chatLatest))
|
||||
#expect(LanguageModel.parse(from: "openai/gpt-5-chat-latest") == .openai(.chatLatest))
|
||||
#expect(try ModelSelector.parseModel("openai/chat-latest") == .openai(.chatLatest))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `parse GPT-5.4 base model`() {
|
||||
let parsed = LanguageModel.parse(from: "gpt-5.4")
|
||||
|
||||
@ -23,6 +23,7 @@ struct OpenAIResponsesProviderTests {
|
||||
let config = self.openAIConfig()
|
||||
|
||||
let gpt5Models: [LanguageModel.OpenAI] = [
|
||||
.chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
@ -82,6 +83,7 @@ struct OpenAIResponsesProviderTests {
|
||||
let config = self.openAIConfig()
|
||||
|
||||
let responsesModels: [LanguageModel.OpenAI] = [
|
||||
.chatLatest,
|
||||
.gpt55,
|
||||
.gpt54,
|
||||
.gpt54Mini,
|
||||
@ -245,6 +247,25 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat-latest Responses payload omits GPT-5 reasoning controls`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
let body = try #require(Self.bodyData(from: request))
|
||||
let json = try JSONSerialization.jsonObject(with: body) as? [String: Any]
|
||||
#expect(json?["model"] as? String == "chat-latest")
|
||||
#expect(json?["reasoning"] == nil)
|
||||
#expect(json?["text"] == nil)
|
||||
|
||||
return NetworkMocking.jsonResponse(for: request, data: Self.responsesPayload(text: "pong"))
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .chatLatest, configuration: config, session: session)
|
||||
_ = try await provider.generateText(request: self.sampleRequest)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `Responses provider resolves OAuth access token`() async throws {
|
||||
try await self.withIsolatedAuthState {
|
||||
@ -497,6 +518,34 @@ struct OpenAIResponsesProviderTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func `chat-latest streams Responses event deltas`() async throws {
|
||||
let config = TachikomaConfiguration(loadFromEnvironment: false)
|
||||
config.setAPIKey("live-openai", for: .openai)
|
||||
|
||||
try await self.withMockedSession { request in
|
||||
#expect(request.url?.path == "/v1/responses")
|
||||
let payload = Self.responsesStreamPayload(chunks: [
|
||||
Self.streamChunkJSON(content: "Hello", finishReason: nil),
|
||||
Self.streamChunkJSON(content: " latest", finishReason: nil),
|
||||
Self.streamChunkJSON(content: nil, finishReason: "stop"),
|
||||
])
|
||||
return NetworkMocking.streamResponse(for: request, data: payload)
|
||||
} operation: { session in
|
||||
let provider = try OpenAIResponsesProvider(model: .chatLatest, configuration: config, session: session)
|
||||
let stream = try await provider.streamText(request: self.sampleRequest)
|
||||
|
||||
var collected = ""
|
||||
for try await delta in stream {
|
||||
if case .textDelta = delta.type {
|
||||
collected.append(delta.content ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
#expect(collected == "Hello latest")
|
||||
}
|
||||
}
|
||||
|
||||
private var sampleRequest: ProviderRequest {
|
||||
ProviderRequest(
|
||||
messages: [ModelMessage(role: .user, content: [.text("ping")])],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user