diff --git a/packages/ai/.changes/service-tier-default-omitted.md b/packages/ai/.changes/service-tier-default-omitted.md new file mode 100644 index 0000000000..4e5773ade6 --- /dev/null +++ b/packages/ai/.changes/service-tier-default-omitted.md @@ -0,0 +1 @@ +- The default service tier is no longer sent explicitly on OpenAI Responses and Codex requests (absence means default; strict endpoints such as Copilot reject the field), and Anthropic cache-write pricing now reprices from a message_delta usage breakdown instead of keeping the message_start rate. diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index a3fcb41521..c26bce63f2 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -6,7 +6,11 @@ import type { MessageParam, RawMessageStreamEvent, } from "@anthropic-ai/sdk/resources/messages.js"; -import { getAnthropicCacheWriteCost, hasStandardAnthropicCachePricing } from "../cache-pricing.js"; +import { + type AnthropicCacheCreationUsage, + getAnthropicCacheWriteCost, + hasStandardAnthropicCachePricing, +} from "../cache-pricing.js"; import { getEnvApiKey } from "../env-api-keys.js"; import { calculateCost, clampThinkingLevel } from "../models.js"; import type { @@ -695,6 +699,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti if (event.usage.cache_creation_input_tokens != null) { output.usage.cacheWrite = event.usage.cache_creation_input_tokens; } + // The SDK's MessageDeltaUsage type omits cache_creation, but the wire carries it. + const deltaCacheCreation = (event.usage as { cache_creation?: AnthropicCacheCreationUsage | null }) + .cache_creation; + if (cacheControl && usesAnthropicCachePricing && deltaCacheCreation) { + cacheWriteCost = getAnthropicCacheWriteCost( + model.cost.input, + cacheControl.ttl === "1h" ? "1h" : "5m", + deltaCacheCreation, + ); + } output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost( diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index cf9f113a86..68d59ef8b1 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -342,7 +342,8 @@ function buildRequestBody( body.temperature = options.temperature; } - if (options?.serviceTier !== undefined) { + // "default" means "no tier request": sending it explicitly breaks strict endpoints (e.g. Copilot). + if (options?.serviceTier !== undefined && options.serviceTier !== "default") { body.service_tier = options.serviceTier; } diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index 0370d63fcc..af0af322c7 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -237,7 +237,8 @@ function buildParams(model: Model<"openai-responses">, context: Context, options params.temperature = options?.temperature; } - if (options?.serviceTier !== undefined) { + // "default" means "no tier request": sending it explicitly breaks strict endpoints (e.g. Copilot). + if (options?.serviceTier !== undefined && options.serviceTier !== "default") { params.service_tier = options.serviceTier; } diff --git a/packages/ai/test/anthropic-sse-parsing.test.ts b/packages/ai/test/anthropic-sse-parsing.test.ts index 147eb874d4..781437d643 100644 --- a/packages/ai/test/anthropic-sse-parsing.test.ts +++ b/packages/ai/test/anthropic-sse-parsing.test.ts @@ -153,6 +153,52 @@ describe("Anthropic raw SSE parsing", () => { expect(result.usage.cost.cacheWrite).toBeCloseTo(testCase.expectedCacheWriteCost); }); + it("reprices cache writes from a message_delta usage breakdown", async () => { + const model = getModel("anthropic", "claude-haiku-4-5"); + const response = createSseResponse([ + { + event: "message_start", + data: JSON.stringify({ + type: "message_start", + message: { + id: "msg_delta_reprice", + usage: { + input_tokens: 12, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 1000, + cache_creation: { ephemeral_5m_input_tokens: 1000, ephemeral_1h_input_tokens: 0 }, + }, + }, + }), + }, + { + event: "message_delta", + data: JSON.stringify({ + type: "message_delta", + delta: { stop_reason: "end_turn" }, + usage: { + input_tokens: 12, + output_tokens: 5, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 2000, + cache_creation: { ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 2000 }, + }, + }), + }, + { event: "message_stop", data: JSON.stringify({ type: "message_stop" }) }, + ]); + const result = await streamAnthropic( + model, + { messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }] }, + { client: createFakeAnthropicClient(response), cacheRetention: "long" }, + ).result(); + + expect(result.usage.cacheWrite).toBe(2000); + // 2000 one-hour tokens at 2x input cost, not the stale 1.25x rate from message_start. + expect(result.usage.cost.cacheWrite).toBeCloseTo(0.004, 6); + }); + it("preserves configured cache write pricing for non-Anthropic models", async () => { const model = getModel("minimax", "MiniMax-M2.7-highspeed"); const response = createSseResponse( diff --git a/packages/ai/test/openai-codex-stream.test.ts b/packages/ai/test/openai-codex-stream.test.ts index e05b9d9831..10f6889fe2 100644 --- a/packages/ai/test/openai-codex-stream.test.ts +++ b/packages/ai/test/openai-codex-stream.test.ts @@ -561,6 +561,64 @@ describe("openai-codex streaming", () => { await streamResult.result(); }); + it("omits service_tier from the Codex wire body for the default tier", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-")); + process.env.PI_CODING_AGENT_DIR = tempDir; + const token = mockToken(); + const sse = `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 } }, + }, + })}\n\n`; + const encoder = new TextEncoder(); + global.fetch = vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "https://api.github.com/repos/openai/codex/releases/latest") { + return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 }); + } + if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) { + return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } }); + } + if (url === "https://chatgpt.com/backend-api/codex/responses") { + const body = JSON.parse(String(init?.body)) as Record; + expect("service_tier" in body).toBe(false); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sse)); + controller.close(); + }, + }); + return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const model: Model<"openai-codex-responses"> = { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + api: "openai-codex-responses", + provider: "openai-codex", + baseUrl: "https://chatgpt.com/backend-api", + reasoning: true, + input: ["text"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }; + const context: Context = { + systemPrompt: "You are a helpful assistant.", + messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], + }; + + const result = await streamOpenAICodexResponses(model, context, { + apiKey: token, + serviceTier: "default", + }).result(); + expect(result.stopReason).toBe("stop"); + }); + it.each([ ["gpt-5.1-codex", "flex", 0.5], ["gpt-5.1-codex", "priority", 2], diff --git a/packages/ai/test/openai-responses-copilot-provider.test.ts b/packages/ai/test/openai-responses-copilot-provider.test.ts index c4140a5c00..1b50406983 100644 --- a/packages/ai/test/openai-responses-copilot-provider.test.ts +++ b/packages/ai/test/openai-responses-copilot-provider.test.ts @@ -212,6 +212,32 @@ describe("openai-responses provider defaults", () => { expect(captured).toEqual({ sessionId: null, clientRequestId: null }); }); + it("omits service_tier from the wire body for the default tier", async () => { + const model = getModel("openai", "gpt-5.4"); + const sse = `data: ${JSON.stringify({ + type: "response.completed", + response: { + status: "completed", + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2, input_tokens_details: { cached_tokens: 0 } }, + }, + })}\n\n`; + let wireBody: Record | undefined; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => { + wireBody = JSON.parse(String(init?.body)) as Record; + return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } }); + }); + + const result = await streamOpenAIResponses( + model, + { systemPrompt: "sys", messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, + { apiKey: "test-key", serviceTier: "default" }, + ).result(); + + expect(result.stopReason).toBe("stop"); + // Absence IS the default; sending it explicitly breaks strict endpoints (e.g. Copilot). + expect(wireBody && "service_tier" in wireBody).toBe(false); + }); + it.each([ ["gpt-5.4", "priority", 2], ["gpt-5.5", "priority", 2.5], diff --git a/packages/coding-agent/.changes/zai-default-model.md b/packages/coding-agent/.changes/zai-default-model.md new file mode 100644 index 0000000000..3afdf4bb1c --- /dev/null +++ b/packages/coding-agent/.changes/zai-default-model.md @@ -0,0 +1 @@ +- The zai provider default model now points at glm-5.3; the previous default was removed from the catalog and silently fell back to a template model. diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 950020e703..6e8404276a 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -33,7 +33,7 @@ export const defaultModelPerProvider: Record = { xai: "grok-4.20-0309-reasoning", groq: "openai/gpt-oss-120b", cerebras: "gpt-oss-120b", - zai: "glm-5.1", + zai: "glm-5.3", mistral: "devstral-medium-latest", minimax: "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", diff --git a/packages/coding-agent/test/model-resolver.test.ts b/packages/coding-agent/test/model-resolver.test.ts index 4988f7ad48..1ba1588821 100644 --- a/packages/coding-agent/test/model-resolver.test.ts +++ b/packages/coding-agent/test/model-resolver.test.ts @@ -1,4 +1,4 @@ -import type { Model } from "@earendil-works/pi-ai"; +import { getModels, type KnownProvider, type Model } from "@earendil-works/pi-ai"; import { describe, expect, test, vi } from "vitest"; import { defaultModelPerProvider, @@ -303,8 +303,19 @@ describe("default model selection", () => { expect(defaultModelPerProvider["prime-inference"]).toBe("z-ai/glm-5.2"); }); + test("every per-provider default exists in the model catalog", () => { + for (const [provider, modelId] of Object.entries(defaultModelPerProvider)) { + const models = getModels(provider as KnownProvider); + if (models.length === 0) continue; + expect( + models.map((model) => model.id), + `default for ${provider}`, + ).toContain(modelId); + } + }); + test("zai, minimax, and cerebras defaults track current models", () => { - expect(defaultModelPerProvider.zai).toBe("glm-5.1"); + expect(defaultModelPerProvider.zai).toBe("glm-5.3"); expect(defaultModelPerProvider.minimax).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider["minimax-cn"]).toBe("MiniMax-M2.7"); expect(defaultModelPerProvider.cerebras).toBe("gpt-oss-120b");