Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .cursor/rules/vendors-in-guest.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ The factory builds Go from vendor docs. That guest owns how to connect, send, an

The host must not implement Slack, Telegram, Discord, WhatsApp, or other vendor HTTP — no form encoders, users.info, auth.test, conversation-list filters, bundled `contracts/vendors/*.json`, or vendor live/E2E harnesses in this repo.

Host work: forward `http.request` as declared, attach declared secrets as Bearer/Basic, persist `result.emit`, render HostUI.
Do not add code of any kind, for any reason, to fix a vendor-specific cause. That includes host code, contract text, prompts, and hand-edits to one installed guest (error codes, scopes, ids, query flags, display names). If an override is needed, David will say so explicitly. Otherwise do not do it.

If David asks for host-side vendor protocol code, tests, or harnesses, warn first.
Host work: forward `http.request` as declared, attach declared secrets as Bearer/Basic, persist `result.emit`, render HostUI. A live acceptance test performs those same forwards. It must not know which vendor it is calling.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ public enum GeminiModel: String, CaseIterable, Codable, Sendable, AgentModel {
return ModelTokenPricing(inputUSDPer1MTokens: 1.50, outputUSDPer1MTokens: 7.50)
}
}

public var requestSupport: ModelRequestSupport {
switch self {
case .gemini25FlashLite, .gemini31FlashLite, .gemini37Flash:
return ModelRequestSupport(temperature: true)
}
}
}

public struct GeminiProvider: AgentProvider {
Expand All @@ -59,10 +66,11 @@ public struct GeminiProvider: AgentProvider {
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept")
let allowed = request.constrained(to: model)
urlRequest.httpBody = try encode(GeminiStreamRequest(
messages: request.messages,
temperature: request.temperature,
thinking: request.thinking
messages: allowed.messages,
temperature: allowed.temperature,
thinking: allowed.thinking
))

let (bytes, response) = try await transport.bytes(for: urlRequest)
Expand Down Expand Up @@ -105,11 +113,12 @@ public struct GeminiProvider: AgentProvider {
urlRequest.httpMethod = "POST"
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept")
let allowed = request.constrained(to: model)
urlRequest.httpBody = try encode(GeminiJSONStreamRequest(
messages: request.messages,
temperature: request.temperature,
responseSchema: responseSchema,
thinking: request.thinking
messages: allowed.messages,
temperature: allowed.temperature,
responseSchema: responseSchema ?? allowed.responseSchema,
thinking: allowed.thinking
))

let (bytes, response) = try await transport.bytes(for: urlRequest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ extension OpenAIModel {
efforts = [.none, .low, .medium, .high, .xhigh]
case .gpt56Sol:
efforts = [.none, .low, .medium, .high, .xhigh, .max]
case .gpt6Luna, .gpt6Sol:
efforts = [.none, .low, .medium, .high, .xhigh, .max]
case .gpt6Astra:
efforts = [.low, .medium, .high, .xhigh, .max]
}
return efforts.map { effort in
ModelThinkingOption(
Expand All @@ -49,6 +53,10 @@ extension OpenAIModel {
thinkingOptions.first { $0.id == ReasoningEffort.medium.rawValue }
?? thinkingOptions[0]
}

public func acceptsThinking(_ option: ModelThinkingOption) -> Bool {
thinkingOptions.contains { $0.id == option.id && $0.wire == option.wire }
}
}

extension GeminiModel {
Expand Down Expand Up @@ -95,6 +103,10 @@ extension GeminiModel {
}
}

public func acceptsThinking(_ option: ModelThinkingOption) -> Bool {
thinkingOptions.contains { $0.id == option.id && $0.wire == option.wire }
}

private static func levelOptions(_ levels: [ThinkingLevel]) -> [ModelThinkingOption] {
levels.map { level in
ModelThinkingOption(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,31 @@ public enum OpenAIModel: String, CaseIterable, Codable, Sendable, AgentModel {
case gpt56Sol = "gpt-5.6-sol"
case gpt56Terra = "gpt-5.6-terra"
case gpt56Luna = "gpt-5.6-luna"
/// GPT-6 capability tiers.
case gpt6Luna = "gpt-6-luna"
case gpt6Sol = "gpt-6-sol"
case gpt6Astra = "gpt-6-astra"

public var id: AgentModelID {
.init(provider: "openai", name: rawValue)
}

public var maxSupportedContextTokens: Int {
400_000
switch self {
case .gpt6Luna, .gpt6Sol, .gpt6Astra:
return 1_050_000
default:
return 400_000
}
}

public var maxIdealContextTokens: Int {
200_000
switch self {
case .gpt6Luna, .gpt6Sol, .gpt6Astra:
return 272_000
default:
return 200_000
}
}

/// Approximate list prices (USD / 1M tokens). Update when OpenAI changes rates.
Expand All @@ -37,6 +51,20 @@ public enum OpenAIModel: String, CaseIterable, Codable, Sendable, AgentModel {
return ModelTokenPricing(inputUSDPer1MTokens: 1.25, outputUSDPer1MTokens: 10.00)
case .gpt56Sol:
return ModelTokenPricing(inputUSDPer1MTokens: 2.50, outputUSDPer1MTokens: 15.00)
case .gpt6Luna:
return ModelTokenPricing(inputUSDPer1MTokens: 0.10, outputUSDPer1MTokens: 0.50)
case .gpt6Sol:
return ModelTokenPricing(inputUSDPer1MTokens: 2.00, outputUSDPer1MTokens: 10.00)
case .gpt6Astra:
return ModelTokenPricing(inputUSDPer1MTokens: 10.00, outputUSDPer1MTokens: 50.00)
}
}

/// These models reject a caller-supplied temperature and use their own default.
public var requestSupport: ModelRequestSupport {
switch self {
case .gpt54Mini, .gpt54, .gpt55, .gpt56Sol, .gpt56Terra, .gpt56Luna, .gpt6Luna, .gpt6Sol, .gpt6Astra:
return ModelRequestSupport(temperature: false)
}
}
}
Expand All @@ -60,12 +88,13 @@ public struct OpenAIProvider: AgentProvider {
urlRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.setValue("text/event-stream", forHTTPHeaderField: "Accept")
let allowed = request.constrained(to: model)
urlRequest.httpBody = try encode(OpenAIStreamRequest(
model: model.rawValue,
messages: request.messages,
temperature: request.temperature,
responseSchema: request.responseSchema,
thinking: request.thinking
messages: allowed.messages,
temperature: allowed.temperature,
responseSchema: allowed.responseSchema,
thinking: allowed.thinking
))

let (bytes, response) = try await transport.bytes(for: urlRequest)
Expand Down Expand Up @@ -128,13 +157,7 @@ struct OpenAIStreamRequest: Encodable {
self.messages = messages.map(OpenAIMessage.init)
self.stream = true
self.streamOptions = OpenAIStreamOptions(includeUsage: true)

// OpenAI's reasoning-class models (GPT-5 series) lock temperature internally and reject manual settings with HTTP 400.
if model.contains("gpt-5") {
self.temperature = nil
} else {
self.temperature = temperature
}
self.temperature = temperature

if case .openAIReasoningEffort(let effort) = thinking?.wire {
self.reasoningEffort = effort
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import Testing
struct ModelTests {
@Test func openAIModelIdentifier() {
#expect(OpenAIModel.gpt56Luna.id.provider == "openai")
#expect(OpenAIModel.gpt56Luna.id.rawValue == "gpt-5.6-luna")
#expect(OpenAIModel.gpt6Luna.id.rawValue == "gpt-6-luna")
#expect(OpenAIModel.gpt6Sol.id.rawValue == "gpt-6-sol")
#expect(OpenAIModel.gpt6Astra.id.rawValue == "gpt-6-astra")
}

@Test func geminiModelIdentifier() {
Expand Down Expand Up @@ -187,18 +189,59 @@ struct ModelTests {
}

@Test func openAIReasoningEffortSerialization() throws {
let request = OpenAIStreamRequest(
model: "gpt-5.6-sol",
let model = OpenAIModel.gpt56Sol
let allowed = AgentRequest(
messages: [.init(role: .user, content: "hello")],
temperature: 0.1,
responseSchema: nil,
thinking: OpenAIModel.gpt56Sol.thinkingOptions.first { $0.id == "high" }
thinking: model.thinkingOptions.first { $0.id == "high" }
).constrained(to: model)
let request = OpenAIStreamRequest(
model: model.rawValue,
messages: allowed.messages,
temperature: allowed.temperature,
responseSchema: allowed.responseSchema,
thinking: allowed.thinking
)

let data = try JSONEncoder().encode(request)
let jsonString = String(decoding: data, as: UTF8.self)
#expect(jsonString.contains("reasoning_effort"))
#expect(jsonString.contains("high"))
#expect(jsonString.contains("temperature") == false)
}

@Test func providerSendsOnlyFieldsTheModelAccepts() throws {
let model = OpenAIModel.gpt6Astra
#expect(model.requestSupport.temperature == false)
let unsupported = ModelThinkingOption(
id: "none",
displayName: "None",
wire: .openAIReasoningEffort("none")
)
let allowed = AgentRequest(
messages: [.init(role: .user, content: "hello")],
temperature: 0,
thinking: unsupported
).constrained(to: model)
#expect(allowed.temperature == nil)
#expect(allowed.thinking == nil)
let encoded = OpenAIStreamRequest(
model: model.rawValue,
messages: allowed.messages,
temperature: allowed.temperature,
responseSchema: nil,
thinking: allowed.thinking
)
let json = String(decoding: try JSONEncoder().encode(encoded), as: UTF8.self)
#expect(!json.contains("temperature"))
#expect(!json.contains("reasoning_effort"))

let gemini = AgentRequest(
messages: [.init(role: .user, content: "hello")],
temperature: 0
).constrained(to: GeminiModel.gemini37Flash)
#expect(GeminiModel.gemini37Flash.requestSupport.temperature)
#expect(gemini.temperature == 0)
}

@Test func geminiThinkingBudgetSerialization() throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Structure

public actor LiveFactoryBuilder: PluginFactoryBuilder {
private let apiKey: String
private let model: OpenAIModel = .gpt56Luna
private let model: OpenAIModel = .gpt6Luna

public init(apiKey: String) {
self.apiKey = apiKey
Expand Down Expand Up @@ -104,7 +104,7 @@ public actor LiveFactoryBuilder: PluginFactoryBuilder {

public actor LiveFactoryReviewer: PluginFactoryReviewer {
private let apiKey: String
private let model: OpenAIModel = .gpt56Luna
private let model: OpenAIModel = .gpt6Luna

public init(apiKey: String) {
self.apiKey = apiKey
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Plugin
import Structure

/// Production adapter for the Go plugin factory.
public struct GoPluginFactoryDockerExecutor: PluginFactoryExecutor, PluginFactoryCompiledGuestExecutor, Sendable {
public struct GoPluginFactoryDockerExecutor: PluginFactoryExecutor, PluginFactoryCompiledGuestExecutor, PluginFactoryLiveAcceptanceExecutor, Sendable {
private let runtime: GoGuestDockerExecutor

public var image: String { runtime.image }
Expand Down Expand Up @@ -48,6 +48,42 @@ public struct GoPluginFactoryDockerExecutor: PluginFactoryExecutor, PluginFactor
}
}

public func runLiveAcceptance(
artifact: Data,
testInput: Data
) async throws -> PluginFactoryHopTestRun {
let script = try PluginFactoryTestScript.parse(testInput)
let liveHops = script.hops.filter { $0.kind != .httpResults && $0.httpResults?.isEmpty != false }
if liveHops.isEmpty {
let result = PluginFactoryExecutionResult(
exitCode: 1,
stderr: Data(
"Live acceptance needs a hop that is not a fixture http_results body.".utf8
)
)
return PluginFactoryHopTestRun(final: result, hopResults: [result])
}
var hopResults: [PluginFactoryExecutionResult] = []
var lastResult = PluginFactoryExecutionResult(exitCode: 1)
var threadID: String?
for hop in liveHops {
let live = PluginLiveAcceptance.prepared(hop, discoveredThreadID: threadID)
let input = try live.encodeValidated()
let result = try await PluginHostHopDispatcher.run(initialInput: input) { hopInput in
try await self.runtime.runArtifact(artifact: artifact, input: hopInput)
}
hopResults.append(result)
lastResult = result
if threadID == nil {
threadID = PluginLiveAcceptance.firstThreadID(
in: String(decoding: result.stdout, as: UTF8.self)
)
}
guard result.exitCode == 0 else { break }
}
return PluginFactoryHopTestRun(final: lastResult, hopResults: hopResults)
}

public func packageGuestSource(source: String) async throws -> Data {
try await runtime.compileSource(source)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public struct OpenAIScriptReviewer: ScriptReviewer {

public init(
apiKey: String,
model: OpenAIModel = .gpt56Luna,
model: OpenAIModel = .gpt6Luna,
systemPrompt: String = ReviewerSystemPrompt
) {
self.name = "openai-\(model.rawValue)"
Expand All @@ -28,7 +28,7 @@ public struct OpenAIScriptReviewer: ScriptReviewer {

public static func fromEnvironment(
variable: String = "OPENAI_API_KEY",
model: OpenAIModel = .gpt56Luna
model: OpenAIModel = .gpt6Luna
) -> OpenAIScriptReviewer? {
guard let apiKey = ProcessInfo.processInfo.environment[variable], !apiKey.isEmpty else {
return nil
Expand Down
Loading
Loading