mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 20:49:53 +08:00
fix(providers): update the v6 SDK packages and fix Claude and Gemini settings
- Update ai to 6.0.300 and the @ai-sdk providers to their latest v6-line versions. @ai-sdk/anthropic 3.0.47 did not know claude-opus-4-7/4-8 and capped their output at 32000 tokens; 3.0.127 allows 128000 - Drop the fine-grained-tool-streaming beta header for the Anthropic API: the provider now streams tool input per tool (eager_input_streaming) - Claude 4.7 and later reject a non-default temperature/top_p/top_k and the extended thinking budget with a 400. A middleware retries once without them, so TEMPERATURE and *_THINKING_BUDGET_TOKENS no longer break those models - Prompt caching also reaches Claude on the Anthropic API and OpenRouter; before, only Bedrock got a cache marker - GOOGLE_TOP_K and GOOGLE_TOP_P never reached Gemini: they were sent as Google provider options, which drops them. They are call settings now. GOOGLE_CANDIDATE_COUNT and GOOGLE_REASONING_EFFORT, which the provider does not support, are removed - Add @ai-sdk/openai-compatible as a direct dependency
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment node
|
||||
import { generateText, type ModelMessage } from "ai"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { CACHE_POINT, getAIModel } from "@/lib/ai-providers"
|
||||
|
||||
const ENV = ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"]
|
||||
const saved = Object.fromEntries(ENV.map((k) => [k, process.env[k]]))
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV) {
|
||||
if (saved[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = saved[k]
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// The chat route marks its system messages like this
|
||||
const messages: ModelMessage[] = [
|
||||
{ role: "system", content: "Instructions", providerOptions: CACHE_POINT },
|
||||
{ role: "user", content: "hi" },
|
||||
]
|
||||
|
||||
/** Send the messages through a real provider and return the request body */
|
||||
async function requestBody(
|
||||
provider: "anthropic" | "openrouter",
|
||||
reply: unknown,
|
||||
) {
|
||||
let body: any
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: RequestInit) => {
|
||||
body = JSON.parse(init.body as string)
|
||||
return new Response(JSON.stringify(reply), {
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
const { model } = getAIModel({
|
||||
provider,
|
||||
modelId:
|
||||
provider === "anthropic"
|
||||
? "claude-sonnet-4-5"
|
||||
: "anthropic/claude-sonnet-4.5",
|
||||
})
|
||||
await generateText({ model, messages, maxRetries: 0 }).catch(() => {})
|
||||
return body
|
||||
}
|
||||
|
||||
describe("prompt cache breakpoints", () => {
|
||||
it("reach the Anthropic API", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "test-key"
|
||||
const body = await requestBody("anthropic", {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
stop_reason: "end_turn",
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
})
|
||||
expect(body.system).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Instructions",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("reach OpenRouter", async () => {
|
||||
process.env.OPENROUTER_API_KEY = "test-key"
|
||||
const body = await requestBody("openrouter", {
|
||||
id: "gen-1",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "ok" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.stringify(body.messages[0])).toContain(
|
||||
'"cache_control":{"type":"ephemeral"}',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment node
|
||||
import { generateText } from "ai"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { getAIModel } from "@/lib/ai-providers"
|
||||
|
||||
const ENV = ["GOOGLE_GENERATIVE_AI_API_KEY", "GOOGLE_TOP_K", "GOOGLE_TOP_P"]
|
||||
const saved = Object.fromEntries(ENV.map((k) => [k, process.env[k]]))
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV) {
|
||||
if (saved[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = saved[k]
|
||||
}
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Send one request through the real Google provider and return its body */
|
||||
async function googleRequestBody() {
|
||||
let body: any
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (_url: string, init: RequestInit) => {
|
||||
body = JSON.parse(init.body as string)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "ok" }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
const { model } = getAIModel({
|
||||
provider: "google",
|
||||
modelId: "gemini-2.5-flash",
|
||||
})
|
||||
await generateText({ model, prompt: "hi", maxRetries: 0 })
|
||||
return body
|
||||
}
|
||||
|
||||
describe("Google sampling settings", () => {
|
||||
it("sends GOOGLE_TOP_K and GOOGLE_TOP_P in the generation config", async () => {
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "test-key"
|
||||
process.env.GOOGLE_TOP_K = "40"
|
||||
process.env.GOOGLE_TOP_P = "0.9"
|
||||
const body = await googleRequestBody()
|
||||
expect(body.generationConfig).toMatchObject({ topK: 40, topP: 0.9 })
|
||||
})
|
||||
|
||||
it("sends neither when they are not set", async () => {
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = "test-key"
|
||||
delete process.env.GOOGLE_TOP_K
|
||||
delete process.env.GOOGLE_TOP_P
|
||||
const body = await googleRequestBody()
|
||||
expect(body.generationConfig?.topK).toBeUndefined()
|
||||
expect(body.generationConfig?.topP).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment node
|
||||
import { simulateReadableStream, streamText } from "ai"
|
||||
import { MockLanguageModelV3 } from "ai/test"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import {
|
||||
withDeprecatedParamsFallback,
|
||||
withoutDeprecatedParams,
|
||||
} from "@/lib/deprecated-params"
|
||||
|
||||
// The error texts Claude 4.7 and later return (the Anthropic API and Bedrock)
|
||||
const rejection = (message: string) => ({
|
||||
statusCode: 400,
|
||||
message,
|
||||
responseBody: JSON.stringify({ error: { message } }),
|
||||
})
|
||||
const TEMPERATURE = rejection("`temperature` is deprecated for this model.")
|
||||
const THINKING = rejection(
|
||||
'"thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort" to control thinking behavior.',
|
||||
)
|
||||
|
||||
describe("withoutDeprecatedParams", () => {
|
||||
const params = {
|
||||
temperature: 0.2,
|
||||
topP: 0.9,
|
||||
maxOutputTokens: 1000,
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: "enabled", budgetTokens: 4000 },
|
||||
cacheControl: { type: "ephemeral" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
it("drops sampling settings and the thinking budget", () => {
|
||||
for (const error of [TEMPERATURE, THINKING]) {
|
||||
expect(withoutDeprecatedParams(error, params)).toEqual({
|
||||
maxOutputTokens: 1000,
|
||||
providerOptions: {
|
||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("drops a Bedrock thinking budget", () => {
|
||||
const bedrock = {
|
||||
providerOptions: {
|
||||
bedrock: {
|
||||
reasoningConfig: { type: "enabled", budgetTokens: 4000 },
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(withoutDeprecatedParams(THINKING, bedrock)).toEqual({
|
||||
providerOptions: { bedrock: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it("leaves other errors and requests with nothing to drop alone", () => {
|
||||
expect(withoutDeprecatedParams(rejection("bad key"), params)).toBeNull()
|
||||
expect(
|
||||
withoutDeprecatedParams(
|
||||
{ ...TEMPERATURE, statusCode: 401 },
|
||||
params,
|
||||
),
|
||||
).toBeNull()
|
||||
const nothingToDrop = {
|
||||
providerOptions: {
|
||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||
},
|
||||
}
|
||||
expect(withoutDeprecatedParams(TEMPERATURE, nothingToDrop)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("withDeprecatedParamsFallback", () => {
|
||||
it("retries the stream once without the rejected settings", async () => {
|
||||
const calls: any[] = []
|
||||
const model = new MockLanguageModelV3({
|
||||
// The test stream only has the parts this check needs
|
||||
doStream: (async (options: any) => {
|
||||
calls.push(options)
|
||||
if (options.temperature !== undefined) throw TEMPERATURE
|
||||
return {
|
||||
stream: simulateReadableStream({
|
||||
chunks: [
|
||||
{ type: "text-start", id: "t" },
|
||||
{ type: "text-delta", id: "t", delta: "ok" },
|
||||
{ type: "text-end", id: "t" },
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: { unified: "stop", raw: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 1 },
|
||||
outputTokens: { total: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
}) as any,
|
||||
})
|
||||
const result = streamText({
|
||||
model: withDeprecatedParamsFallback(model as any),
|
||||
prompt: "hi",
|
||||
temperature: 0.2,
|
||||
maxRetries: 0,
|
||||
})
|
||||
expect(await result.text).toBe("ok")
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[1].temperature).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user