From 8a9c133c0ef92078f7bc9c3f69502306ad1b2b09 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Tue, 25 Aug 2026 17:33:11 +0800 Subject: [PATCH] Add Codex auto-review model metadata --- .../Core/LLM/ModelCapabilityResolver.swift | 120 +++++++++++++----- Termind/Resources/ModelCatalogOverrides.json | 33 +++++ TermindTests/AIProviderPersistenceTests.swift | 49 +++++++ 3 files changed, 170 insertions(+), 32 deletions(-) create mode 100644 Termind/Resources/ModelCatalogOverrides.json diff --git a/Termind/Core/LLM/ModelCapabilityResolver.swift b/Termind/Core/LLM/ModelCapabilityResolver.swift index 189d9a74..87139752 100644 --- a/Termind/Core/LLM/ModelCapabilityResolver.swift +++ b/Termind/Core/LLM/ModelCapabilityResolver.swift @@ -142,7 +142,7 @@ nonisolated enum ModelCapabilityResolver { private static func fill( _ model: AIModel, reported: ReportedFields, - from record: PiModelRecord + from record: CatalogRecord ) -> AIModel { var result = model if result.displayName.isEmpty { result.displayName = record.name } @@ -152,23 +152,35 @@ nonisolated enum ModelCapabilityResolver { let modalities = Set(record.input.compactMap(AIModel.Modality.init(rawValue:))) result.input = modalities.isEmpty ? [.text] : modalities } - if !reported.contains(.output) { result.output = [.text] } + if !reported.contains(.output) { + let modalities = Set( + (record.output ?? [AIModel.Modality.text.rawValue]) + .compactMap(AIModel.Modality.init(rawValue:)) + ) + result.output = modalities.isEmpty ? [.text] : modalities + } if !reported.contains(.abilities) { - // Pi's language-model catalog is consumed by an agent runtime and - // its provider APIs accept tool schemas. `reasoning` is the one - // model-specific ability Pi records explicitly. - result.abilities = [.toolCall] - if record.reasoning { result.abilities.insert(.reasoning) } + if let abilities = record.abilities { + result.abilities = Set(abilities.compactMap(AIModel.Ability.init(rawValue:))) + } else { + // Pi's language-model catalog is consumed by an agent runtime and + // its provider APIs accept tool schemas. `reasoning` is the one + // model-specific ability Pi records explicitly. + result.abilities = [.toolCall] + if record.reasoning { result.abilities.insert(.reasoning) } + } } if result.contextLength == nil { result.contextLength = record.contextWindow } - if result.maxOutputTokens == nil { result.maxOutputTokens = record.maxOutputTokens } + if result.maxOutputTokens == nil, let maxOutputTokens = record.maxOutputTokens { + result.maxOutputTokens = maxOutputTokens + } return result } private static func reasoning( model: AIModel, provider: ModelProvider, - record: PiModelRecord? + record: CatalogRecord? ) -> ReasoningResolution { guard model.abilities.contains(.reasoning) else { return ReasoningResolution(supported: [.off]) @@ -198,7 +210,7 @@ nonisolated enum ModelCapabilityResolver { } private static func automaticReasoning( - record: PiModelRecord?, + record: CatalogRecord?, provider: ModelProvider ) -> ReasoningResolution { guard let record, record.reasoning else { @@ -209,13 +221,21 @@ nonisolated enum ModelCapabilityResolver { return ReasoningResolution(supported: supported, wireValues: values) } - let unsupported = Set(record.unsupportedEfforts.compactMap(ReasoningEffort.init(piName:))) - var supported = ReasoningEffort.allCases.filter { effort in - if unsupported.contains(effort) { return false } - if effort == .xHigh || effort == .max { - return record.effortWireValues[effort.piName] != nil + var supported: [ReasoningEffort] + if let declared = record.supportedEfforts { + let declaredSet = Set(declared.compactMap(ReasoningEffort.init(piName:))) + supported = ReasoningEffort.allCases.filter(declaredSet.contains) + } else { + let unsupported = Set( + (record.unsupportedEfforts ?? []).compactMap(ReasoningEffort.init(piName:)) + ) + supported = ReasoningEffort.allCases.filter { effort in + if unsupported.contains(effort) { return false } + if effort == .xHigh || effort == .max { + return record.effortWireValues[effort.piName] != nil + } + return true } - return true } if supported.isEmpty { supported = [.off] } @@ -240,13 +260,13 @@ nonisolated enum ModelCapabilityResolver { private static let catalog = PiModelCatalog.load() private struct PiModelCatalog: Sendable { - var recordsByKey: [String: PiModelRecord] - var recordsByID: [String: [PiModelRecord]] + var recordsByKey: [String: CatalogRecord] + var recordsByID: [String: [CatalogRecord]] var metadata: CatalogMetadata? static func load() -> PiModelCatalog { let bundles = [Bundle.main, Bundle(for: BundleToken.self)] - let url = bundles.lazy.compactMap { + let piURL = bundles.lazy.compactMap { $0.url(forResource: "PiModelCatalog", withExtension: "json") ?? $0.url( forResource: "PiModelCatalog", @@ -254,18 +274,47 @@ nonisolated enum ModelCapabilityResolver { subdirectory: "Resources" ) }.first - guard let url, - let data = try? Data(contentsOf: url), + guard let piURL, + let data = try? Data(contentsOf: piURL), let payload = try? JSONDecoder().decode(PiCatalogPayload.self, from: data) else { return PiModelCatalog(recordsByKey: [:], recordsByID: [:], metadata: nil) } - var byKey: [String: PiModelRecord] = [:] - var byID: [String: [PiModelRecord]] = [:] + var byKey: [String: CatalogRecord] = [:] + var byID: [String: [CatalogRecord]] = [:] + func upsert(_ record: CatalogRecord) { + let recordKey = key(provider: record.provider, modelID: record.id) + let normalizedID = normalized(record.id) + byKey[recordKey] = record + byID[normalizedID, default: []].removeAll { + key(provider: $0.provider, modelID: $0.id) == recordKey + } + byID[normalizedID, default: []].append(record) + } + for record in payload.models { - byKey[key(provider: record.provider, modelID: record.id)] = record - byID[record.id.lowercased(), default: []].append(record) + upsert(record) + } + + let overridesURL = bundles.lazy.compactMap { + $0.url(forResource: "ModelCatalogOverrides", withExtension: "json") + ?? $0.url( + forResource: "ModelCatalogOverrides", + withExtension: "json", + subdirectory: "Resources" + ) + }.first + if let overridesURL, + let overridesData = try? Data(contentsOf: overridesURL), + let overrides = try? JSONDecoder().decode( + CatalogOverridesPayload.self, + from: overridesData + ) + { + for record in overrides.models { + upsert(record) + } } return PiModelCatalog( recordsByKey: byKey, @@ -279,7 +328,7 @@ nonisolated enum ModelCapabilityResolver { ) } - func record(modelID: String, provider: ModelProvider) -> PiModelRecord? { + func record(modelID: String, provider: ModelProvider) -> CatalogRecord? { let normalizedID = Self.normalized(modelID) let preferredProvider = Self.piProviderID(for: provider) @@ -302,9 +351,9 @@ nonisolated enum ModelCapabilityResolver { } private static func directVendorRecord( - in records: [PiModelRecord], + in records: [CatalogRecord], modelID: String - ) -> PiModelRecord? { + ) -> CatalogRecord? { let namespace = modelID.split(separator: "/", maxSplits: 1).first.map(String.init) let aliases: [String: String] = [ "openai": "openai", @@ -358,18 +407,25 @@ nonisolated enum ModelCapabilityResolver { var package: String var version: String var commit: String - var models: [PiModelRecord] + var models: [CatalogRecord] } - private struct PiModelRecord: Decodable, Sendable { + private struct CatalogOverridesPayload: Decodable, Sendable { + var models: [CatalogRecord] + } + + private struct CatalogRecord: Decodable, Sendable { var provider: String var id: String var name: String var reasoning: Bool var input: [String] + var output: [String]? + var abilities: [String]? var contextWindow: Int - var maxOutputTokens: Int - var unsupportedEfforts: [String] = [] + var maxOutputTokens: Int? + var supportedEfforts: [String]? + var unsupportedEfforts: [String]? var effortWireValues: [String: String] = [:] } diff --git a/Termind/Resources/ModelCatalogOverrides.json b/Termind/Resources/ModelCatalogOverrides.json new file mode 100644 index 00000000..55e6e5d2 --- /dev/null +++ b/Termind/Resources/ModelCatalogOverrides.json @@ -0,0 +1,33 @@ +{ + "models": [ + { + "provider": "openai", + "id": "codex-auto-review", + "name": "Codex Auto-review", + "reasoning": true, + "input": [ + "text", + "image" + ], + "output": [ + "text" + ], + "abilities": [ + "toolCall", + "reasoning" + ], + "contextWindow": 272000, + "supportedEfforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "effortWireValues": { + "xhigh": "xhigh", + "max": "max" + } + } + ] +} diff --git a/TermindTests/AIProviderPersistenceTests.swift b/TermindTests/AIProviderPersistenceTests.swift index 6f007d31..07935ca5 100644 --- a/TermindTests/AIProviderPersistenceTests.swift +++ b/TermindTests/AIProviderPersistenceTests.swift @@ -161,6 +161,55 @@ struct ModelCapabilityResolverTests { #expect(resolution.supported == [.off, .low, .medium, .high, .xHigh]) } + @Test + func bundledOverrideEnrichesCodexAutoReview() { + let provider = ModelProvider(name: "OpenAI") + let resolution = ModelCapabilityResolver.resolve( + model: AIModel(id: "codex-auto-review"), + provider: provider + ) + + #expect(resolution.catalogSource == .init(provider: "openai", modelID: "codex-auto-review")) + #expect(resolution.model.displayName == "Codex Auto-review") + #expect(resolution.model.ownedBy == "openai") + #expect(resolution.model.input == [.text, .image]) + #expect(resolution.model.output == [.text]) + #expect(resolution.model.abilities == [.toolCall, .reasoning]) + #expect(resolution.model.contextLength == 272_000) + #expect(resolution.model.maxOutputTokens == nil) + #expect(resolution.supported == [.low, .medium, .high, .xHigh, .max]) + #expect(resolution.wireValue(for: .xHigh) == "xhigh") + #expect(resolution.wireValue(for: .max) == "max") + #expect(resolution.clamp(.off) == .low) + #expect(resolution.clamp(.minimal) == .low) + } + + @Test + func reportedFieldsWinOverCodexAutoReviewOverride() { + let provider = ModelProvider(name: "OpenAI") + var model = AIModel(id: "codex-auto-review") + model.kind = .other + model.input = [.audio] + model.output = [.image] + model.abilities = [] + model.contextLength = 7 + model.maxOutputTokens = 8 + + let resolution = ModelCapabilityResolver.resolve( + model: model, + provider: provider, + reported: [.kind, .input, .output, .abilities] + ) + + #expect(resolution.model.kind == .other) + #expect(resolution.model.input == [.audio]) + #expect(resolution.model.output == [.image]) + #expect(resolution.model.abilities.isEmpty) + #expect(resolution.model.contextLength == 7) + #expect(resolution.model.maxOutputTokens == 8) + #expect(resolution.supported == [.off]) + } + @Test func explicitlyReportedFieldsWinOverPiCatalog() { let provider = ModelProvider(name: "OpenAI")