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
7 changes: 5 additions & 2 deletions Sources/Tachikoma/Core/Generation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,7 @@ public func streamText(
}
}

if buffersUntilDone, !didReceiveTerminal, !bufferedDeltas.isEmpty {
if buffersUntilDone, !didReceiveTerminal {
throw TachikomaError.apiError("Stream ended before provider completion status was received")
}

Expand Down Expand Up @@ -1049,7 +1049,10 @@ extension LanguageModel {
fallbackContent.append(.text(text))
}
fallbackContent.append(contentsOf: missingToolCalls.map { .toolCall($0) })
history.append(ModelMessage(role: .assistant, content: fallbackContent))
let fallbackMetadata = needsFallbackBoundary
? MessageMetadata(customData: ["tachikoma.internal.boundary": "reasoning_only"])
: nil
history.append(ModelMessage(role: .assistant, content: fallbackContent, metadata: fallbackMetadata))
return history
}

Expand Down
53 changes: 53 additions & 0 deletions Tests/TachikomaTests/Core/GenerationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1258,6 +1258,29 @@ struct GenerationTests {
.canonical("https://example.test"))
}

@Test
func `GenerateText tags fallback reasoning-only boundary for Anthropic-compatible Fable`() async throws {
let providerResponse = ProviderResponse(
text: "",
finishReason: .stop,
reasoning: [ProviderReasoningBlock(text: "private", signature: "sig")],
)
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in StaticProvider(response: providerResponse) }

let result = try await generateText(
model: .anthropicCompatible(modelId: "claude-fable-5", baseURL: "https://example.test"),
messages: [.user("hi")],
configuration: config,
)

#expect(result.messages.count == 3)
#expect(result.messages[1].channel == .thinking)
#expect(result.messages[2].role == .assistant)
#expect(result.messages[2].content == [.text("")])
#expect(result.messages[2].metadata?.customData?["tachikoma.internal.boundary"] == "reasoning_only")
}

@Test
func `GenerateText tags fallback reasoning for direct custom Fable`() async throws {
let provider = StaticProvider(
Expand Down Expand Up @@ -1483,6 +1506,36 @@ struct GenerationTests {
#expect(operation.usage.outputTokens > 0)
}

@Test
func `StreamText buffered mode fails when provider ends without terminal status`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
config.setProviderFactoryOverride { _, _ in
StaticProvider(
response: ProviderResponse(text: "", finishReason: .stop),
capabilities: ModelCapabilities(supportsStreaming: true),
streamDeltas: [],
)
}

let result = try await streamText(
model: .openaiCompatible(modelId: "compatible-model", baseURL: "https://example.test"),
messages: [.user("hi")],
settings: GenerationSettings(streamBuffering: .untilTerminal),
configuration: config,
)

do {
for try await _ in result.stream {}
Issue.record("Expected missing terminal status error")
} catch let error as TachikomaError {
guard case let .apiError(message) = error else {
Issue.record("Expected apiError, got \(error)")
return
}
#expect(message.contains("provider completion status"))
}
}

@Test
func `StreamText stop conditions ignore reasoning deltas`() async throws {
let config = TachikomaConfiguration(loadFromEnvironment: false)
Expand Down
Loading