Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/ai/.changes/service-tier-default-omitted.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 15 additions & 1 deletion packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
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 {
Expand Down Expand Up @@ -462,7 +466,7 @@
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api as Api,

Check failure on line 469 in packages/ai/src/providers/anthropic.ts

View workflow job for this annotation

GitHub Actions / Test (ai)

Unhandled error

TypeError: Cannot read properties of undefined (reading 'api') ❯ src/providers/anthropic.ts:469:15 ❯ streamAnthropic src/providers/anthropic.ts:744:3 ❯ test/github-copilot-anthropic.test.ts:92:13 This error originated in "test/github-copilot-anthropic.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "includes interleaved-thinking beta when reasoning is enabled". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.
provider: model.provider,
model: model.id,
usage: {
Expand Down Expand Up @@ -695,6 +699,16 @@
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(
Expand Down
3 changes: 2 additions & 1 deletion packages/ai/src/providers/openai-codex-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
3 changes: 2 additions & 1 deletion packages/ai/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
46 changes: 46 additions & 0 deletions packages/ai/test/anthropic-sse-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
58 changes: 58 additions & 0 deletions packages/ai/test/openai-codex-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
expect("service_tier" in body).toBe(false);
const stream = new ReadableStream<Uint8Array>({
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],
Expand Down
26 changes: 26 additions & 0 deletions packages/ai/test/openai-responses-copilot-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => {
wireBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
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],
Expand Down
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/zai-default-model.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/coding-agent/src/core/model-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const defaultModelPerProvider: Record<KnownProvider, string> = {
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",
Expand Down
15 changes: 13 additions & 2 deletions packages/coding-agent/test/model-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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");
Expand Down
Loading