Compare commits

...

2 Commits

Author SHA1 Message Date
Peter Steinberger
f5cde9fac1
style: format chat-latest support 2026-06-06 18:06:08 -07:00
Peter Steinberger
4538eddfac
feat: support OpenAI chat-latest 2026-06-06 17:12:34 -07:00
14 changed files with 127 additions and 25 deletions

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}

View File

@ -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 {

View File

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

View File

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

View File

@ -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)

View File

@ -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)

View File

@ -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))

View File

@ -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")

View File

@ -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")])],