Skip to content
Closed
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 @@ 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 {
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 3 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,9 @@ function buildParams(model: Model<"openai-responses">, context: Context, options
params.temperature = options?.temperature;
}

if (options?.serviceTier !== undefined) {
// GitHub Copilot rejects the service_tier FIELD itself (400) for every value.
// Elsewhere it is always sent: absence means "auto" (project tier), not "default".
if (options?.serviceTier !== undefined && model.provider !== "github-copilot") {
params.service_tier = options.serviceTier;
}

Expand Down
35 changes: 28 additions & 7 deletions packages/ai/test/anthropic-sse-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,13 @@ function createFakeAnthropicClient(response: Response): Anthropic {
} as unknown as Anthropic;
}

function createCacheUsageEvents(cacheCreation: {
ephemeral_5m_input_tokens: number;
ephemeral_1h_input_tokens: number;
}): Array<{ event: string; data: string }> {
const cacheWriteTokens = cacheCreation.ephemeral_5m_input_tokens + cacheCreation.ephemeral_1h_input_tokens;
type CacheCreation = { ephemeral_5m_input_tokens: number; ephemeral_1h_input_tokens: number };

function createCacheUsageEvents(
cacheCreation: CacheCreation,
deltaCacheCreation?: CacheCreation,
): Array<{ event: string; data: string }> {
const tokens = (c: CacheCreation) => c.ephemeral_5m_input_tokens + c.ephemeral_1h_input_tokens;
return [
{
event: "message_start",
Expand All @@ -94,7 +96,7 @@ function createCacheUsageEvents(cacheCreation: {
input_tokens: 12,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: cacheWriteTokens,
cache_creation_input_tokens: tokens(cacheCreation),
cache_creation: cacheCreation,
},
},
Expand All @@ -109,7 +111,8 @@ function createCacheUsageEvents(cacheCreation: {
input_tokens: 12,
output_tokens: 5,
cache_read_input_tokens: 0,
cache_creation_input_tokens: cacheWriteTokens,
cache_creation_input_tokens: tokens(deltaCacheCreation ?? cacheCreation),
...(deltaCacheCreation ? { cache_creation: deltaCacheCreation } : {}),
},
}),
},
Expand Down Expand Up @@ -153,6 +156,24 @@ 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(
createCacheUsageEvents(
{ ephemeral_5m_input_tokens: 1000, ephemeral_1h_input_tokens: 0 },
{ ephemeral_5m_input_tokens: 0, ephemeral_1h_input_tokens: 2000 },
),
);
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
2 changes: 2 additions & 0 deletions packages/ai/test/openai-codex-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,8 @@ describe("openai-codex streaming", () => {
});

it.each([
// "default" must stay on the wire: absence means "auto" (the project tier).
["gpt-5.1-codex", "default", 1],
["gpt-5.1-codex", "flex", 0.5],
["gpt-5.1-codex", "priority", 2],
["gpt-5.4", "priority", 2],
Expand Down
35 changes: 35 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,41 @@ describe("openai-responses provider defaults", () => {
expect(captured).toEqual({ sessionId: null, clientRequestId: null });
});

it.each([
["github-copilot" as const, "auto" as const, false],
["github-copilot" as const, "default" as const, false],
["openai" as const, "default" as const, true],
])("scopes service_tier serialization to the provider (%s, %s)", async (provider, serviceTier, expected) => {
const base = getModel("openai", "gpt-5.4");
const model = { ...base, provider };
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 },
).result();

expect(result.stopReason).toBe("stop");
// Copilot rejects the FIELD for every value; elsewhere absence means "auto"
// (the project tier), so an explicit "default" must stay on the wire.
expect(wireBody && "service_tier" in wireBody).toBe(expected);
if (expected) {
expect((wireBody as Record<string, unknown>).service_tier).toBe(serviceTier);
}
});

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