diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 64bace68..cda06de3 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `AssistantMessage.responseModel` on the openai-completions path: surfaces the concrete `chunk.model` when it differs from the requested id (e.g. OpenRouter `auto` -> `anthropic/...`). + ### Fixed - Fixed DeepSeek V4 Flash `xhigh` thinking support so requests preserve `xhigh` and map it to DeepSeek's `max` reasoning effort ([#3944](https://github.com/badlogic/pi-mono/issues/3944)). diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 7af3b897..7062dac2 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -207,6 +207,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, // and each chunk in a streamed completion carries the same id. output.responseId ||= chunk.id; + if (typeof chunk.model === "string" && chunk.model.length > 0 && chunk.model !== model.id) { + output.responseModel ||= chunk.model; + } if (chunk.usage) { output.usage = parseChunkUsage(chunk.usage, model); } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 8ffd785f..f9a3f2b0 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -216,6 +216,7 @@ export interface AssistantMessage { api: Api; provider: Provider; model: string; + responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`) responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one usage: Usage; stopReason: StopReason; diff --git a/packages/ai/test/openai-completions-response-model.test.ts b/packages/ai/test/openai-completions-response-model.test.ts new file mode 100644 index 00000000..32d4edf7 --- /dev/null +++ b/packages/ai/test/openai-completions-response-model.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { complete } from "../src/stream.js"; +import type { Model } from "../src/types.js"; + +// Router/virtual ids (e.g. OpenRouter `auto`) keep `model` pinned to the +// requested id and surface the routed concrete id on `responseModel`. + +const mockState = vi.hoisted(() => ({ + chunks: [] as unknown[], +})); + +vi.mock("openai", () => { + class FakeOpenAI { + chat = { + completions: { + create: () => { + const chunks = mockState.chunks; + const stream = { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) yield chunk; + }, + }; + const promise = Promise.resolve(stream) as Promise & { + withResponse: () => Promise<{ + data: typeof stream; + response: { status: number; headers: Headers }; + }>; + }; + promise.withResponse = async () => ({ + data: stream, + response: { status: 200, headers: new Headers() }, + }); + return promise; + }, + }, + }; + } + return { default: FakeOpenAI }; +}); + +function openRouterAuto(): Model<"openai-completions"> { + return { + id: "openrouter/auto", + name: "OpenRouter Auto", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200_000, + maxTokens: 8192, + }; +} + +describe("openai-completions responseModel", () => { + beforeEach(() => { + mockState.chunks = []; + }); + + it("surfaces routed chunk.model on responseModel without changing model", async () => { + mockState.chunks = [ + { id: "chatcmpl-1", model: "anthropic/claude-opus-4.7", choices: [{ index: 0, delta: { content: "hi" } }] }, + { + id: "chatcmpl-1", + model: "anthropic/claude-opus-4.7", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + + const message = await complete( + openRouterAuto(), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.model).toBe("openrouter/auto"); + expect(message.responseModel).toBe("anthropic/claude-opus-4.7"); + expect(message.provider).toBe("openrouter"); + expect(message.stopReason).toBe("stop"); + }); + + it("leaves responseModel undefined when chunks echo the requested id", async () => { + mockState.chunks = [ + { id: "chatcmpl-2", model: "openrouter/auto", choices: [{ index: 0, delta: { content: "hi" } }] }, + { + id: "chatcmpl-2", + model: "openrouter/auto", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + + const message = await complete( + openRouterAuto(), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.model).toBe("openrouter/auto"); + expect(message.responseModel).toBeUndefined(); + }); + + it("ignores empty or missing chunk.model", async () => { + mockState.chunks = [ + { id: "chatcmpl-3", choices: [{ index: 0, delta: { content: "hi" } }] }, + { id: "chatcmpl-3", model: "", choices: [{ index: 0, delta: { content: "!" } }] }, + { + id: "chatcmpl-3", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { + prompt_tokens: 1, + completion_tokens: 2, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + + const message = await complete( + openRouterAuto(), + { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test" }, + ); + + expect(message.model).toBe("openrouter/auto"); + expect(message.responseModel).toBeUndefined(); + }); +});