Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- packages/ai/src/providers/anthropic.ts +59 -0
- packages/ai/src/providers/baseten.ts +15 -0
- packages/ai/src/providers/cerebras.models.ts +8 -0
- packages/ai/src/providers/cloudflare-ai-gateway.models.ts +8 -0
- packages/ai/src/providers/data-json.d.ts +4 -0
- packages/ai/src/providers/deepseek.ts +15 -0
- packages/ai/src/providers/fireworks.ts +19 -0
- packages/ai/src/providers/google-vertex.ts +100 -0
- packages/ai/src/providers/huggingface.ts +15 -0
- packages/ai/src/providers/minimax.models.ts +8 -0
- packages/ai/src/providers/mistral.models.ts +8 -0
- packages/ai/src/providers/moonshotai-cn.models.ts +8 -0
- packages/ai/src/providers/openai-codex.ts +22 -0
- packages/ai/src/providers/openai.ts +15 -0
- packages/ai/src/providers/opencode-go.models.ts +8 -0
- packages/ai/src/providers/openrouter.models.ts +8 -0
- packages/ai/src/providers/qwen-token-plan-individual.models.ts +8 -0
- packages/ai/src/providers/qwen-token-plan.models.ts +8 -0
- packages/ai/src/providers/radius.ts +82 -0
- packages/ai/src/providers/xai.ts +24 -0
- packages/ai/src/providers/xiaomi-token-plan-cn.models.ts +8 -0
- packages/ai/src/providers/xiaomi-token-plan-sgp.models.ts +8 -0
- packages/ai/src/providers/xiaomi.ts +15 -0
- packages/ai/src/providers/zai-coding-cn.models.ts +8 -0
- packages/ai/test/abort.test.ts +351 -0
- packages/ai/test/anthropic-cache-write-1h-cost.test.ts +86 -0
- packages/ai/test/anthropic-long-cache-retention-e2e.test.ts +127 -0
- packages/ai/test/anthropic-mid-conversation-effort.test.ts +212 -0
- packages/ai/test/anthropic-oauth.test.ts +144 -0
- packages/ai/test/anthropic-opus-4-8-smoke.test.ts +71 -0
- packages/ai/test/anthropic-sse-parsing.test.ts +585 -0
- packages/ai/test/anthropic-thinking-binding-e2e.test.ts +59 -0
- packages/ai/test/anthropic-thinking-disable.test.ts +172 -0
- packages/ai/test/assistant-message-frame.test.ts +606 -0
- packages/ai/test/azure-openai-base-url.test.ts +227 -0
- packages/ai/test/azure-openai-tool-choice.test.ts +79 -0
- packages/ai/test/azure-utils.ts +28 -0
- packages/ai/test/baseten-models.test.ts +106 -0
- packages/ai/test/bedrock-credentials.test.ts +115 -0
- packages/ai/test/bedrock-custom-headers.test.ts +202 -0
- packages/ai/test/bedrock-endpoint-resolution.test.ts +208 -0
- packages/ai/test/bedrock-models.test.ts +70 -0
- packages/ai/test/bedrock-redacted-reasoning.test.ts +274 -0
- packages/ai/test/bedrock-response-headers.test.ts +63 -0
- packages/ai/test/bedrock-thinking-payload.test.ts +266 -0
- packages/ai/test/cloudflare-utils.ts +9 -0
- packages/ai/test/compat-env.test.ts +74 -0
- packages/ai/test/cross-provider-handoff.test.ts +522 -0
- packages/ai/test/deferred-tools.test.ts +550 -0
- packages/ai/test/empty.test.ts +822 -0
packages/ai/src/providers/anthropic.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
| 2 |
+
import { lazyOAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { loadAnthropicOAuth } from "../auth/oauth/load.ts";
|
| 4 |
+
import type { ApiKeyAuth } from "../auth/types.ts";
|
| 5 |
+
import { ANTHROPIC_API_KEY_ENV, ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../env-api-keys.ts";
|
| 6 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 7 |
+
import { ANTHROPIC_MODELS } from "./anthropic.models.ts";
|
| 8 |
+
|
| 9 |
+
function anthropicApiKeyAuth(): ApiKeyAuth {
|
| 10 |
+
return {
|
| 11 |
+
name: "Anthropic API key",
|
| 12 |
+
login: async (interaction) => {
|
| 13 |
+
interaction.signal.throwIfAborted();
|
| 14 |
+
const key = await interaction.prompt({ type: "secret", message: "Enter Anthropic API key" });
|
| 15 |
+
interaction.signal.throwIfAborted();
|
| 16 |
+
return { type: "api_key", key };
|
| 17 |
+
},
|
| 18 |
+
resolve: async ({ ctx, credential, signal }) => {
|
| 19 |
+
signal.throwIfAborted();
|
| 20 |
+
if (credential?.key) {
|
| 21 |
+
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const authToken = await ctx.env(ANTHROPIC_AUTH_TOKEN_ENV);
|
| 25 |
+
signal.throwIfAborted();
|
| 26 |
+
if (authToken) {
|
| 27 |
+
return {
|
| 28 |
+
auth: { headers: { Authorization: `Bearer ${authToken}` } },
|
| 29 |
+
source: ANTHROPIC_AUTH_TOKEN_ENV,
|
| 30 |
+
};
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
for (const envVar of [ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV]) {
|
| 34 |
+
const apiKey = await ctx.env(envVar);
|
| 35 |
+
signal.throwIfAborted();
|
| 36 |
+
if (apiKey) return { auth: { apiKey }, source: envVar };
|
| 37 |
+
}
|
| 38 |
+
return undefined;
|
| 39 |
+
},
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function anthropicProvider(): Provider<"anthropic-messages"> {
|
| 44 |
+
return createProvider({
|
| 45 |
+
id: "anthropic",
|
| 46 |
+
name: "Anthropic",
|
| 47 |
+
baseUrl: "https://api.anthropic.com",
|
| 48 |
+
auth: {
|
| 49 |
+
apiKey: anthropicApiKeyAuth(),
|
| 50 |
+
oauth: lazyOAuth({
|
| 51 |
+
name: "Anthropic (Claude Pro/Max)",
|
| 52 |
+
isSubscription: true,
|
| 53 |
+
load: loadAnthropicOAuth,
|
| 54 |
+
}),
|
| 55 |
+
},
|
| 56 |
+
models: Object.values(ANTHROPIC_MODELS),
|
| 57 |
+
api: anthropicMessagesApi(),
|
| 58 |
+
});
|
| 59 |
+
}
|
packages/ai/src/providers/baseten.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 4 |
+
import { BASETEN_MODELS } from "./baseten.models.ts";
|
| 5 |
+
|
| 6 |
+
export function basetenProvider(): Provider<"openai-completions"> {
|
| 7 |
+
return createProvider({
|
| 8 |
+
id: "baseten",
|
| 9 |
+
name: "Baseten",
|
| 10 |
+
baseUrl: "https://inference.baseten.co/v1",
|
| 11 |
+
auth: { apiKey: envApiKeyAuth("Baseten API key", ["BASETEN_API_KEY"]) },
|
| 12 |
+
models: Object.values(BASETEN_MODELS),
|
| 13 |
+
api: openAICompletionsApi(),
|
| 14 |
+
});
|
| 15 |
+
}
|
packages/ai/src/providers/cerebras.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/cerebras.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const CEREBRAS_MODELS: ModelCatalog<typeof values, "cerebras"> =
|
| 8 |
+
flattenModelCatalog("cerebras", values);
|
packages/ai/src/providers/cloudflare-ai-gateway.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/cloudflare-ai-gateway.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const CLOUDFLARE_AI_GATEWAY_MODELS: ModelCatalog<typeof values, "cloudflare-ai-gateway"> =
|
| 8 |
+
flattenModelCatalog("cloudflare-ai-gateway", values);
|
packages/ai/src/providers/data-json.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare module "*.json" {
|
| 2 |
+
const value: unknown;
|
| 3 |
+
export default value;
|
| 4 |
+
}
|
packages/ai/src/providers/deepseek.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 4 |
+
import { DEEPSEEK_MODELS } from "./deepseek.models.ts";
|
| 5 |
+
|
| 6 |
+
export function deepseekProvider(): Provider<"openai-completions"> {
|
| 7 |
+
return createProvider({
|
| 8 |
+
id: "deepseek",
|
| 9 |
+
name: "DeepSeek",
|
| 10 |
+
baseUrl: "https://api.deepseek.com",
|
| 11 |
+
auth: { apiKey: envApiKeyAuth("DeepSeek API key", ["DEEPSEEK_API_KEY"]) },
|
| 12 |
+
models: Object.values(DEEPSEEK_MODELS),
|
| 13 |
+
api: openAICompletionsApi(),
|
| 14 |
+
});
|
| 15 |
+
}
|
packages/ai/src/providers/fireworks.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
| 2 |
+
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
| 3 |
+
import { envApiKeyAuth } from "../auth/helpers.ts";
|
| 4 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 5 |
+
import { FIREWORKS_MODELS } from "./fireworks.models.ts";
|
| 6 |
+
|
| 7 |
+
export function fireworksProvider(): Provider<"anthropic-messages" | "openai-completions"> {
|
| 8 |
+
return createProvider({
|
| 9 |
+
id: "fireworks",
|
| 10 |
+
name: "Fireworks",
|
| 11 |
+
baseUrl: "https://api.fireworks.ai/inference",
|
| 12 |
+
auth: { apiKey: envApiKeyAuth("Fireworks API key", ["FIREWORKS_API_KEY"]) },
|
| 13 |
+
models: Object.values(FIREWORKS_MODELS),
|
| 14 |
+
api: {
|
| 15 |
+
"anthropic-messages": anthropicMessagesApi(),
|
| 16 |
+
"openai-completions": openAICompletionsApi(),
|
| 17 |
+
},
|
| 18 |
+
});
|
| 19 |
+
}
|
packages/ai/src/providers/google-vertex.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { googleVertexApi } from "../api/google-vertex.lazy.ts";
|
| 2 |
+
import type { ApiKeyAuth } from "../auth/types.ts";
|
| 3 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 4 |
+
import { GOOGLE_VERTEX_MODELS } from "./google-vertex.models.ts";
|
| 5 |
+
|
| 6 |
+
const VERTEX_ADC_PATH = "~/.config/gcloud/application_default_credentials.json";
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Vertex accepts an explicit API key or Application Default Credentials
|
| 10 |
+
* (`gcloud auth application-default login`). ADC additionally requires
|
| 11 |
+
* project and location env vars, which the implementation reads itself.
|
| 12 |
+
*/
|
| 13 |
+
const vertexAuth: ApiKeyAuth = {
|
| 14 |
+
name: "Google Cloud credentials",
|
| 15 |
+
login: async (interaction) => {
|
| 16 |
+
interaction.signal.throwIfAborted();
|
| 17 |
+
const method = await interaction.prompt({
|
| 18 |
+
type: "select",
|
| 19 |
+
message: "Select Google Vertex AI authentication method:",
|
| 20 |
+
options: [
|
| 21 |
+
{ id: "api-key", label: "Google Cloud API key" },
|
| 22 |
+
{ id: "adc", label: "Application Default Credentials" },
|
| 23 |
+
{ id: "service-account", label: "Service account credentials file" },
|
| 24 |
+
],
|
| 25 |
+
});
|
| 26 |
+
interaction.signal.throwIfAborted();
|
| 27 |
+
if (method === "api-key") {
|
| 28 |
+
return {
|
| 29 |
+
type: "api_key",
|
| 30 |
+
key: await interaction.prompt({ type: "secret", message: "Enter Google Cloud API key" }),
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
if (method !== "adc" && method !== "service-account") {
|
| 34 |
+
throw new Error(`Unknown Google Vertex AI auth method: ${method}`);
|
| 35 |
+
}
|
| 36 |
+
interaction.notify({
|
| 37 |
+
type: "info",
|
| 38 |
+
message:
|
| 39 |
+
method === "adc"
|
| 40 |
+
? "Run `gcloud auth application-default login`, then provide the project and location."
|
| 41 |
+
: "Provide a service account credentials file, project, and location.",
|
| 42 |
+
links: [
|
| 43 |
+
{
|
| 44 |
+
label: "Application Default Credentials",
|
| 45 |
+
url: "https://cloud.google.com/docs/authentication/provide-credentials-adc",
|
| 46 |
+
},
|
| 47 |
+
],
|
| 48 |
+
});
|
| 49 |
+
const project = await interaction.prompt({ type: "text", message: "Enter Google Cloud project ID" });
|
| 50 |
+
const location = await interaction.prompt({ type: "text", message: "Enter Google Cloud location" });
|
| 51 |
+
const credentialsPath =
|
| 52 |
+
method === "service-account"
|
| 53 |
+
? await interaction.prompt({ type: "text", message: "Enter service account credentials file path" })
|
| 54 |
+
: undefined;
|
| 55 |
+
return {
|
| 56 |
+
type: "api_key",
|
| 57 |
+
env: {
|
| 58 |
+
GOOGLE_CLOUD_PROJECT: project,
|
| 59 |
+
GOOGLE_CLOUD_LOCATION: location,
|
| 60 |
+
...(credentialsPath ? { GOOGLE_APPLICATION_CREDENTIALS: credentialsPath } : {}),
|
| 61 |
+
},
|
| 62 |
+
};
|
| 63 |
+
},
|
| 64 |
+
resolve: async ({ ctx, credential, signal }) => {
|
| 65 |
+
const env = async (name: string) => {
|
| 66 |
+
signal.throwIfAborted();
|
| 67 |
+
const value = await ctx.env(name);
|
| 68 |
+
signal.throwIfAborted();
|
| 69 |
+
return value;
|
| 70 |
+
};
|
| 71 |
+
const key = credential?.key ?? (await env("GOOGLE_CLOUD_API_KEY"));
|
| 72 |
+
if (key) return { auth: { apiKey: key }, source: credential?.key ? "stored credential" : "GOOGLE_CLOUD_API_KEY" };
|
| 73 |
+
|
| 74 |
+
const adcPath = credential?.env?.GOOGLE_APPLICATION_CREDENTIALS ?? (await env("GOOGLE_APPLICATION_CREDENTIALS"));
|
| 75 |
+
signal.throwIfAborted();
|
| 76 |
+
const hasCredentials = await ctx.fileExists(adcPath ?? VERTEX_ADC_PATH);
|
| 77 |
+
signal.throwIfAborted();
|
| 78 |
+
const project =
|
| 79 |
+
credential?.env?.GOOGLE_CLOUD_PROJECT ?? (await env("GOOGLE_CLOUD_PROJECT")) ?? (await env("GCLOUD_PROJECT"));
|
| 80 |
+
const location = credential?.env?.GOOGLE_CLOUD_LOCATION ?? (await env("GOOGLE_CLOUD_LOCATION"));
|
| 81 |
+
if (hasCredentials && project && location) {
|
| 82 |
+
return {
|
| 83 |
+
auth: {},
|
| 84 |
+
env: credential?.env,
|
| 85 |
+
source: credential ? "stored credential" : "gcloud application default credentials",
|
| 86 |
+
};
|
| 87 |
+
}
|
| 88 |
+
return undefined;
|
| 89 |
+
},
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
export function googleVertexProvider(): Provider<"google-vertex"> {
|
| 93 |
+
return createProvider({
|
| 94 |
+
id: "google-vertex",
|
| 95 |
+
name: "Google Vertex AI",
|
| 96 |
+
auth: { apiKey: vertexAuth },
|
| 97 |
+
models: Object.values(GOOGLE_VERTEX_MODELS),
|
| 98 |
+
api: googleVertexApi(),
|
| 99 |
+
});
|
| 100 |
+
}
|
packages/ai/src/providers/huggingface.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 4 |
+
import { HUGGINGFACE_MODELS } from "./huggingface.models.ts";
|
| 5 |
+
|
| 6 |
+
export function huggingfaceProvider(): Provider<"openai-completions"> {
|
| 7 |
+
return createProvider({
|
| 8 |
+
id: "huggingface",
|
| 9 |
+
name: "Hugging Face",
|
| 10 |
+
baseUrl: "https://router.huggingface.co/v1",
|
| 11 |
+
auth: { apiKey: envApiKeyAuth("Hugging Face token", ["HF_TOKEN"]) },
|
| 12 |
+
models: Object.values(HUGGINGFACE_MODELS),
|
| 13 |
+
api: openAICompletionsApi(),
|
| 14 |
+
});
|
| 15 |
+
}
|
packages/ai/src/providers/minimax.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/minimax.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const MINIMAX_MODELS: ModelCatalog<typeof values, "minimax"> =
|
| 8 |
+
flattenModelCatalog("minimax", values);
|
packages/ai/src/providers/mistral.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/mistral.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const MISTRAL_MODELS: ModelCatalog<typeof values, "mistral"> =
|
| 8 |
+
flattenModelCatalog("mistral", values);
|
packages/ai/src/providers/moonshotai-cn.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/moonshotai-cn.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const MOONSHOTAI_CN_MODELS: ModelCatalog<typeof values, "moonshotai-cn"> =
|
| 8 |
+
flattenModelCatalog("moonshotai-cn", values);
|
packages/ai/src/providers/openai-codex.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAICodexResponsesApi } from "../api/openai-codex-responses.lazy.ts";
|
| 2 |
+
import { lazyOAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { loadOpenAICodexOAuth } from "../auth/oauth/load.ts";
|
| 4 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 5 |
+
import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts";
|
| 6 |
+
|
| 7 |
+
export function openaiCodexProvider(): Provider<"openai-codex-responses"> {
|
| 8 |
+
return createProvider({
|
| 9 |
+
id: "openai-codex",
|
| 10 |
+
name: "OpenAI Codex",
|
| 11 |
+
baseUrl: "https://chatgpt.com/backend-api",
|
| 12 |
+
auth: {
|
| 13 |
+
oauth: lazyOAuth({
|
| 14 |
+
name: "OpenAI (ChatGPT Plus/Pro)",
|
| 15 |
+
isSubscription: true,
|
| 16 |
+
load: loadOpenAICodexOAuth,
|
| 17 |
+
}),
|
| 18 |
+
},
|
| 19 |
+
models: Object.values(OPENAI_CODEX_MODELS),
|
| 20 |
+
api: openAICodexResponsesApi(),
|
| 21 |
+
});
|
| 22 |
+
}
|
packages/ai/src/providers/openai.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 4 |
+
import { OPENAI_MODELS } from "./openai.models.ts";
|
| 5 |
+
|
| 6 |
+
export function openaiProvider(): Provider<"openai-responses"> {
|
| 7 |
+
return createProvider({
|
| 8 |
+
id: "openai",
|
| 9 |
+
name: "OpenAI",
|
| 10 |
+
baseUrl: "https://api.openai.com/v1",
|
| 11 |
+
auth: { apiKey: envApiKeyAuth("OpenAI API key", ["OPENAI_API_KEY"]) },
|
| 12 |
+
models: Object.values(OPENAI_MODELS),
|
| 13 |
+
api: openAIResponsesApi(),
|
| 14 |
+
});
|
| 15 |
+
}
|
packages/ai/src/providers/opencode-go.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/opencode-go.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const OPENCODE_GO_MODELS: ModelCatalog<typeof values, "opencode-go"> =
|
| 8 |
+
flattenModelCatalog("opencode-go", values);
|
packages/ai/src/providers/openrouter.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/openrouter.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const OPENROUTER_MODELS: ModelCatalog<typeof values, "openrouter"> =
|
| 8 |
+
flattenModelCatalog("openrouter", values);
|
packages/ai/src/providers/qwen-token-plan-individual.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/qwen-token-plan-individual.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS: ModelCatalog<typeof values, "qwen-token-plan-individual"> =
|
| 8 |
+
flattenModelCatalog("qwen-token-plan-individual", values);
|
packages/ai/src/providers/qwen-token-plan.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/qwen-token-plan.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const QWEN_TOKEN_PLAN_MODELS: ModelCatalog<typeof values, "qwen-token-plan"> =
|
| 8 |
+
flattenModelCatalog("qwen-token-plan", values);
|
packages/ai/src/providers/radius.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { piMessagesApi } from "../api/pi-messages.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { loadRadiusOAuth } from "../auth/oauth/load.ts";
|
| 4 |
+
import type { Provider } from "../models.ts";
|
| 5 |
+
import {
|
| 6 |
+
DEFAULT_RADIUS_GATEWAY,
|
| 7 |
+
getRadiusModels,
|
| 8 |
+
getRadiusModelsFromConfig,
|
| 9 |
+
loadRadiusGatewayConfig,
|
| 10 |
+
normalizeRadiusGatewayUrl,
|
| 11 |
+
} from "./radius-config.ts";
|
| 12 |
+
|
| 13 |
+
export interface RadiusProviderOptions {
|
| 14 |
+
id?: string;
|
| 15 |
+
name?: string;
|
| 16 |
+
gateway?: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/** Radius gateway provider with a persisted, dynamically refreshed catalog. */
|
| 20 |
+
export function radiusProvider(options: RadiusProviderOptions = {}): Provider<"pi-messages"> {
|
| 21 |
+
const id = options.id ?? "radius";
|
| 22 |
+
const name = options.name ?? "Radius";
|
| 23 |
+
const gateway = normalizeRadiusGatewayUrl(options.gateway ?? DEFAULT_RADIUS_GATEWAY);
|
| 24 |
+
let models = getRadiusModels(id, undefined);
|
| 25 |
+
const streams = piMessagesApi();
|
| 26 |
+
|
| 27 |
+
return {
|
| 28 |
+
id,
|
| 29 |
+
name,
|
| 30 |
+
auth: {
|
| 31 |
+
apiKey: envApiKeyAuth("Radius API key", ["RADIUS_API_KEY"]),
|
| 32 |
+
oauth: lazyOAuth({ name, load: () => loadRadiusOAuth({ name, gateway }) }),
|
| 33 |
+
},
|
| 34 |
+
getModels: () => models,
|
| 35 |
+
refreshModels: async (context) => {
|
| 36 |
+
const stored = context.stored;
|
| 37 |
+
if (stored) {
|
| 38 |
+
const restored = stored.models.filter((model) => model.provider === id) as typeof models;
|
| 39 |
+
if (
|
| 40 |
+
!(await context.publish({
|
| 41 |
+
update: () => {
|
| 42 |
+
models = restored;
|
| 43 |
+
},
|
| 44 |
+
}))
|
| 45 |
+
) {
|
| 46 |
+
return;
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Import catalogs cached by the pre-ModelsStore Radius implementation.
|
| 51 |
+
if (!stored && context.credential?.type === "oauth") {
|
| 52 |
+
const legacy = getRadiusModels(id, context.credential);
|
| 53 |
+
if (legacy.length > 0) {
|
| 54 |
+
if (
|
| 55 |
+
!(await context.publish({
|
| 56 |
+
persist: { models: legacy, checkedAt: Date.now() },
|
| 57 |
+
update: () => {
|
| 58 |
+
models = legacy;
|
| 59 |
+
},
|
| 60 |
+
}))
|
| 61 |
+
) {
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (!context.allowNetwork || context.signal.aborted) return;
|
| 68 |
+
const apiKey = context.credential?.type === "oauth" ? context.credential.access : context.credential?.key;
|
| 69 |
+
const config = await loadRadiusGatewayConfig(gateway, apiKey, context.signal);
|
| 70 |
+
if (context.signal.aborted) return;
|
| 71 |
+
const refreshed = getRadiusModelsFromConfig(id, config);
|
| 72 |
+
await context.publish({
|
| 73 |
+
persist: { models: refreshed, checkedAt: Date.now() },
|
| 74 |
+
update: () => {
|
| 75 |
+
models = refreshed;
|
| 76 |
+
},
|
| 77 |
+
});
|
| 78 |
+
},
|
| 79 |
+
stream: (model, context, streamOptions) => streams.stream(model, context, streamOptions),
|
| 80 |
+
streamSimple: (model, context, streamOptions) => streams.streamSimple(model, context, streamOptions),
|
| 81 |
+
};
|
| 82 |
+
}
|
packages/ai/src/providers/xai.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { loadXaiOAuth } from "../auth/oauth/load.ts";
|
| 4 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 5 |
+
import { XAI_MODELS } from "./xai.models.ts";
|
| 6 |
+
|
| 7 |
+
export function xaiProvider(): Provider<"openai-responses"> {
|
| 8 |
+
return createProvider({
|
| 9 |
+
id: "xai",
|
| 10 |
+
name: "xAI",
|
| 11 |
+
baseUrl: "https://api.x.ai/v1",
|
| 12 |
+
auth: {
|
| 13 |
+
apiKey: envApiKeyAuth("xAI API key", ["XAI_API_KEY"]),
|
| 14 |
+
oauth: lazyOAuth({
|
| 15 |
+
name: "xAI (Grok/X subscription)",
|
| 16 |
+
isSubscription: true,
|
| 17 |
+
loginLabel: "Sign in with SuperGrok or X Premium",
|
| 18 |
+
load: loadXaiOAuth,
|
| 19 |
+
}),
|
| 20 |
+
},
|
| 21 |
+
models: Object.values(XAI_MODELS),
|
| 22 |
+
api: openAIResponsesApi(),
|
| 23 |
+
});
|
| 24 |
+
}
|
packages/ai/src/providers/xiaomi-token-plan-cn.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/xiaomi-token-plan-cn.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const XIAOMI_TOKEN_PLAN_CN_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-cn"> =
|
| 8 |
+
flattenModelCatalog("xiaomi-token-plan-cn", values);
|
packages/ai/src/providers/xiaomi-token-plan-sgp.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/xiaomi-token-plan-sgp.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const XIAOMI_TOKEN_PLAN_SGP_MODELS: ModelCatalog<typeof values, "xiaomi-token-plan-sgp"> =
|
| 8 |
+
flattenModelCatalog("xiaomi-token-plan-sgp", values);
|
packages/ai/src/providers/xiaomi.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
| 2 |
+
import { envApiKeyAuth } from "../auth/helpers.ts";
|
| 3 |
+
import { createProvider, type Provider } from "../models.ts";
|
| 4 |
+
import { XIAOMI_MODELS } from "./xiaomi.models.ts";
|
| 5 |
+
|
| 6 |
+
export function xiaomiProvider(): Provider<"openai-completions"> {
|
| 7 |
+
return createProvider({
|
| 8 |
+
id: "xiaomi",
|
| 9 |
+
name: "Xiaomi",
|
| 10 |
+
baseUrl: "https://api.xiaomimimo.com/v1",
|
| 11 |
+
auth: { apiKey: envApiKeyAuth("Xiaomi API key", ["XIAOMI_API_KEY"]) },
|
| 12 |
+
models: Object.values(XIAOMI_MODELS),
|
| 13 |
+
api: openAICompletionsApi(),
|
| 14 |
+
});
|
| 15 |
+
}
|
packages/ai/src/providers/zai-coding-cn.models.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// This file is auto-generated by scripts/generate-models.ts
|
| 2 |
+
// Do not edit manually - run 'npm run generate-models' to update
|
| 3 |
+
|
| 4 |
+
import values from "./data/zai-coding-cn.json" with { type: "json" };
|
| 5 |
+
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
| 6 |
+
|
| 7 |
+
export const ZAI_CODING_CN_MODELS: ModelCatalog<typeof values, "zai-coding-cn"> =
|
| 8 |
+
flattenModelCatalog("zai-coding-cn", values);
|
packages/ai/test/abort.test.ts
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { complete, getModel, stream } from "../src/compat.ts";
|
| 3 |
+
import type { Api, Context, Model, StreamOptions } from "../src/types.ts";
|
| 4 |
+
|
| 5 |
+
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
|
| 6 |
+
|
| 7 |
+
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
|
| 8 |
+
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
| 9 |
+
import { resolveApiKey } from "./oauth.ts";
|
| 10 |
+
|
| 11 |
+
// Resolve OAuth tokens at module level (async, runs before tests)
|
| 12 |
+
const [openaiCodexToken] = await Promise.all([resolveApiKey("openai-codex")]);
|
| 13 |
+
|
| 14 |
+
async function testAbortSignal<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 15 |
+
const context: Context = {
|
| 16 |
+
messages: [
|
| 17 |
+
{
|
| 18 |
+
role: "user",
|
| 19 |
+
content: "What is 15 + 27? Think step by step. Then list 50 first names.",
|
| 20 |
+
timestamp: Date.now(),
|
| 21 |
+
},
|
| 22 |
+
],
|
| 23 |
+
systemPrompt: "You are a helpful assistant.",
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
let abortFired = false;
|
| 27 |
+
let text = "";
|
| 28 |
+
const controller = new AbortController();
|
| 29 |
+
const response = await stream(llm, context, { ...options, signal: controller.signal });
|
| 30 |
+
for await (const event of response) {
|
| 31 |
+
if (abortFired) return;
|
| 32 |
+
if (event.type === "text_delta" || event.type === "thinking_delta") {
|
| 33 |
+
text += event.delta;
|
| 34 |
+
}
|
| 35 |
+
if (text.length >= 50) {
|
| 36 |
+
controller.abort();
|
| 37 |
+
abortFired = true;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
const msg = await response.result();
|
| 41 |
+
|
| 42 |
+
// If we get here without throwing, the abort didn't work
|
| 43 |
+
expect(msg.stopReason).toBe("aborted");
|
| 44 |
+
expect(msg.content.length).toBeGreaterThan(0);
|
| 45 |
+
|
| 46 |
+
context.messages.push(msg);
|
| 47 |
+
context.messages.push({
|
| 48 |
+
role: "user",
|
| 49 |
+
content: "Please continue, but only generate 5 names.",
|
| 50 |
+
timestamp: Date.now(),
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
const followUp = await complete(llm, context, options);
|
| 54 |
+
expect(followUp.stopReason).toBe("stop");
|
| 55 |
+
expect(followUp.content.length).toBeGreaterThan(0);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
async function testImmediateAbort<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 59 |
+
const controller = new AbortController();
|
| 60 |
+
|
| 61 |
+
controller.abort();
|
| 62 |
+
|
| 63 |
+
const context: Context = {
|
| 64 |
+
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const response = await complete(llm, context, { ...options, signal: controller.signal });
|
| 68 |
+
expect(response.stopReason).toBe("aborted");
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
async function testAbortThenNewMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 72 |
+
// First request: abort immediately before any response content arrives
|
| 73 |
+
const controller = new AbortController();
|
| 74 |
+
controller.abort();
|
| 75 |
+
|
| 76 |
+
const context: Context = {
|
| 77 |
+
messages: [{ role: "user", content: "Hello, how are you?", timestamp: Date.now() }],
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const abortedResponse = await complete(llm, context, { ...options, signal: controller.signal });
|
| 81 |
+
expect(abortedResponse.stopReason).toBe("aborted");
|
| 82 |
+
// The aborted message has empty content since we aborted before anything arrived
|
| 83 |
+
expect(abortedResponse.content.length).toBe(0);
|
| 84 |
+
|
| 85 |
+
// Add the aborted assistant message to context (this is what happens in the real coding agent)
|
| 86 |
+
context.messages.push(abortedResponse);
|
| 87 |
+
|
| 88 |
+
// Second request: send a new message - this should work even with the aborted message in context
|
| 89 |
+
context.messages.push({
|
| 90 |
+
role: "user",
|
| 91 |
+
content: "What is 2 + 2?",
|
| 92 |
+
timestamp: Date.now(),
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const followUp = await complete(llm, context, options);
|
| 96 |
+
expect(followUp.stopReason).toBe("stop");
|
| 97 |
+
expect(followUp.content.length).toBeGreaterThan(0);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
describe("AI Providers Abort Tests", () => {
|
| 101 |
+
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Abort", () => {
|
| 102 |
+
const llm = getModel("google", "gemini-2.5-flash");
|
| 103 |
+
|
| 104 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 105 |
+
await testAbortSignal(llm, { thinking: { enabled: true } });
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 109 |
+
await testImmediateAbort(llm, { thinking: { enabled: true } });
|
| 110 |
+
});
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider Abort", () => {
|
| 114 |
+
const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!;
|
| 115 |
+
void _compat;
|
| 116 |
+
const llm: Model<"openai-completions"> = {
|
| 117 |
+
...baseModel,
|
| 118 |
+
api: "openai-completions",
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 122 |
+
await testAbortSignal(llm);
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 126 |
+
await testImmediateAbort(llm);
|
| 127 |
+
});
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider Abort", () => {
|
| 131 |
+
const llm = getModel("openai", "gpt-5-mini");
|
| 132 |
+
|
| 133 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 134 |
+
await testAbortSignal(llm);
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 138 |
+
await testImmediateAbort(llm);
|
| 139 |
+
});
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Abort", () => {
|
| 143 |
+
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 144 |
+
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
|
| 145 |
+
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
|
| 146 |
+
|
| 147 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 148 |
+
await testAbortSignal(llm, azureOptions);
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 152 |
+
await testImmediateAbort(llm, azureOptions);
|
| 153 |
+
});
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
describe.skipIf(!process.env.ANTHROPIC_OAUTH_TOKEN)("Anthropic Provider Abort", () => {
|
| 157 |
+
const llm = getModel("anthropic", "claude-sonnet-4-6");
|
| 158 |
+
|
| 159 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 160 |
+
await testAbortSignal(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 164 |
+
await testImmediateAbort(llm, { thinkingEnabled: true, thinkingBudgetTokens: 2048 });
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
|
| 168 |
+
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Abort", () => {
|
| 169 |
+
const llm = getModel("mistral", "devstral-medium-latest");
|
| 170 |
+
|
| 171 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 172 |
+
await testAbortSignal(llm);
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 176 |
+
await testImmediateAbort(llm);
|
| 177 |
+
});
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
+
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Abort", () => {
|
| 181 |
+
const llm = getModel("together", "moonshotai/Kimi-K2.6");
|
| 182 |
+
|
| 183 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 184 |
+
await testAbortSignal(llm, { reasoningEffort: "high" });
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 188 |
+
await testImmediateAbort(llm, { reasoningEffort: "high" });
|
| 189 |
+
});
|
| 190 |
+
});
|
| 191 |
+
|
| 192 |
+
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Abort", () => {
|
| 193 |
+
const llm = getModel("baseten", "zai-org/GLM-5.2");
|
| 194 |
+
|
| 195 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 196 |
+
await testAbortSignal(llm, { reasoningEffort: "high" });
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 200 |
+
await testImmediateAbort(llm, { reasoningEffort: "high" });
|
| 201 |
+
});
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Abort", () => {
|
| 205 |
+
const llm = getModel("minimax", "MiniMax-M2.7");
|
| 206 |
+
|
| 207 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 208 |
+
await testAbortSignal(llm);
|
| 209 |
+
});
|
| 210 |
+
|
| 211 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 212 |
+
await testImmediateAbort(llm);
|
| 213 |
+
});
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Abort", () => {
|
| 217 |
+
const llm = getModel("xiaomi", "mimo-v2.5-pro");
|
| 218 |
+
|
| 219 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 220 |
+
await testAbortSignal(llm);
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 224 |
+
await testImmediateAbort(llm);
|
| 225 |
+
});
|
| 226 |
+
});
|
| 227 |
+
|
| 228 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN) Provider Abort", () => {
|
| 229 |
+
const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
|
| 230 |
+
|
| 231 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 232 |
+
await testAbortSignal(llm);
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 236 |
+
await testImmediateAbort(llm);
|
| 237 |
+
});
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS) Provider Abort", () => {
|
| 241 |
+
const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
|
| 242 |
+
|
| 243 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 244 |
+
await testAbortSignal(llm);
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 248 |
+
await testImmediateAbort(llm);
|
| 249 |
+
});
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP) Provider Abort", () => {
|
| 253 |
+
const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
|
| 254 |
+
|
| 255 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 256 |
+
await testAbortSignal(llm);
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 260 |
+
await testImmediateAbort(llm);
|
| 261 |
+
});
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Abort", () => {
|
| 265 |
+
const llm = getModel("qwen-token-plan", "qwen3.7-max");
|
| 266 |
+
|
| 267 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 268 |
+
await testAbortSignal(llm);
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 272 |
+
await testImmediateAbort(llm);
|
| 273 |
+
});
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Abort", () => {
|
| 277 |
+
const llm = getModel("qwen-token-plan-individual", "qwen3.8-max");
|
| 278 |
+
|
| 279 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 280 |
+
await testAbortSignal(llm);
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 284 |
+
await testImmediateAbort(llm);
|
| 285 |
+
});
|
| 286 |
+
});
|
| 287 |
+
|
| 288 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Abort", () => {
|
| 289 |
+
const llm = getModel("qwen-token-plan-cn", "qwen3.7-max");
|
| 290 |
+
|
| 291 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 292 |
+
await testAbortSignal(llm);
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 296 |
+
await testImmediateAbort(llm);
|
| 297 |
+
});
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Abort", () => {
|
| 301 |
+
const llm = getModel("kimi-coding", "kimi-for-coding");
|
| 302 |
+
|
| 303 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 304 |
+
await testAbortSignal(llm);
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 308 |
+
await testImmediateAbort(llm);
|
| 309 |
+
});
|
| 310 |
+
});
|
| 311 |
+
|
| 312 |
+
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Abort", () => {
|
| 313 |
+
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
| 314 |
+
|
| 315 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 316 |
+
await testAbortSignal(llm);
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 320 |
+
await testImmediateAbort(llm);
|
| 321 |
+
});
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
describe("OpenAI Codex Provider Abort", () => {
|
| 325 |
+
it.skipIf(!openaiCodexToken)("should abort mid-stream", { retry: 3 }, async () => {
|
| 326 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 327 |
+
await testAbortSignal(llm, { apiKey: openaiCodexToken });
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
it.skipIf(!openaiCodexToken)("should handle immediate abort", { retry: 3 }, async () => {
|
| 331 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 332 |
+
await testImmediateAbort(llm, { apiKey: openaiCodexToken });
|
| 333 |
+
});
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Abort", () => {
|
| 337 |
+
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 338 |
+
|
| 339 |
+
it("should abort mid-stream", { retry: 3 }, async () => {
|
| 340 |
+
await testAbortSignal(llm, { reasoning: "medium" });
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
it("should handle immediate abort", { retry: 3 }, async () => {
|
| 344 |
+
await testImmediateAbort(llm);
|
| 345 |
+
});
|
| 346 |
+
|
| 347 |
+
it("should handle abort then new message", { retry: 3 }, async () => {
|
| 348 |
+
await testAbortThenNewMessage(llm);
|
| 349 |
+
});
|
| 350 |
+
});
|
| 351 |
+
});
|
packages/ai/test/anthropic-cache-write-1h-cost.test.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type Anthropic from "@anthropic-ai/sdk";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
| 4 |
+
import { getModel } from "../src/compat.ts";
|
| 5 |
+
import type { Context } from "../src/types.ts";
|
| 6 |
+
|
| 7 |
+
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
|
| 8 |
+
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
|
| 9 |
+
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
function createFakeAnthropicClient(response: Response): Anthropic {
|
| 13 |
+
return {
|
| 14 |
+
beta: { messages: { create: () => ({ asResponse: async () => response }) } },
|
| 15 |
+
} as unknown as Anthropic;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function eventsWithCacheCreation(
|
| 19 |
+
cacheCreation: Record<string, number> | undefined,
|
| 20 |
+
): Array<{ event: string; data: string }> {
|
| 21 |
+
const startUsage: Record<string, unknown> = {
|
| 22 |
+
input_tokens: 100,
|
| 23 |
+
output_tokens: 0,
|
| 24 |
+
cache_read_input_tokens: 0,
|
| 25 |
+
cache_creation_input_tokens: 1_000_000,
|
| 26 |
+
};
|
| 27 |
+
if (cacheCreation) startUsage.cache_creation = cacheCreation;
|
| 28 |
+
return [
|
| 29 |
+
{
|
| 30 |
+
event: "message_start",
|
| 31 |
+
data: JSON.stringify({ type: "message_start", message: { id: "msg_test", usage: startUsage } }),
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
event: "content_block_start",
|
| 35 |
+
data: JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }),
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
event: "content_block_delta",
|
| 39 |
+
data: JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hi" } }),
|
| 40 |
+
},
|
| 41 |
+
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
|
| 42 |
+
{
|
| 43 |
+
event: "message_delta",
|
| 44 |
+
data: JSON.stringify({
|
| 45 |
+
type: "message_delta",
|
| 46 |
+
delta: { stop_reason: "end_turn" },
|
| 47 |
+
usage: {
|
| 48 |
+
input_tokens: 100,
|
| 49 |
+
output_tokens: 5,
|
| 50 |
+
cache_read_input_tokens: 0,
|
| 51 |
+
cache_creation_input_tokens: 1_000_000,
|
| 52 |
+
},
|
| 53 |
+
}),
|
| 54 |
+
},
|
| 55 |
+
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
|
| 56 |
+
];
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// claude-opus-4-8: input 5, cacheWrite (5m) 6.25 per Mtok. 1h write = 2x input = 10.
|
| 60 |
+
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
|
| 61 |
+
|
| 62 |
+
describe("Anthropic 1h cache write cost", () => {
|
| 63 |
+
it("prices the 1h portion at 2x input and the rest at the 5m rate", async () => {
|
| 64 |
+
const model = getModel("anthropic", "claude-opus-4-8");
|
| 65 |
+
const response = createSseResponse(
|
| 66 |
+
eventsWithCacheCreation({ ephemeral_5m_input_tokens: 600_000, ephemeral_1h_input_tokens: 400_000 }),
|
| 67 |
+
);
|
| 68 |
+
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
|
| 69 |
+
|
| 70 |
+
expect(result.usage.cacheWrite).toBe(1_000_000);
|
| 71 |
+
expect(result.usage.cacheWrite1h).toBe(400_000);
|
| 72 |
+
// 600k * 6.25/Mtok + 400k * 10/Mtok = 3.75 + 4.0 = 7.75
|
| 73 |
+
expect(result.usage.cost.cacheWrite).toBeCloseTo(7.75, 10);
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
+
it("falls back to the 5m rate when no breakdown is reported", async () => {
|
| 77 |
+
const model = getModel("anthropic", "claude-opus-4-8");
|
| 78 |
+
const response = createSseResponse(eventsWithCacheCreation(undefined));
|
| 79 |
+
const result = await streamAnthropic(model, context, { client: createFakeAnthropicClient(response) }).result();
|
| 80 |
+
|
| 81 |
+
expect(result.usage.cacheWrite).toBe(1_000_000);
|
| 82 |
+
expect(result.usage.cacheWrite1h ?? 0).toBe(0);
|
| 83 |
+
// 1M * 6.25/Mtok = 6.25
|
| 84 |
+
expect(result.usage.cost.cacheWrite).toBeCloseTo(6.25, 10);
|
| 85 |
+
});
|
| 86 |
+
});
|
packages/ai/test/anthropic-long-cache-retention-e2e.test.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { type BuiltinProvider, complete, getModels, getProviders } from "../src/compat.ts";
|
| 3 |
+
import { getEnvApiKey } from "../src/env-api-keys.ts";
|
| 4 |
+
import type { Api, KnownProvider, Model, ProviderStreamOptions } from "../src/types.ts";
|
| 5 |
+
import { resolveApiKey } from "./oauth.ts";
|
| 6 |
+
|
| 7 |
+
const githubCopilotToken = await resolveApiKey("github-copilot");
|
| 8 |
+
|
| 9 |
+
interface AnthropicLongCacheRetentionE2ECase {
|
| 10 |
+
name: string;
|
| 11 |
+
provider: BuiltinProvider;
|
| 12 |
+
model: Model<"anthropic-messages">;
|
| 13 |
+
apiKey: string | undefined;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function getE2EApiKey(provider: KnownProvider): string | undefined {
|
| 17 |
+
if (provider === "github-copilot") {
|
| 18 |
+
return githubCopilotToken;
|
| 19 |
+
}
|
| 20 |
+
return getEnvApiKey(provider);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function getAnthropicMessagesModels(provider: BuiltinProvider): Model<"anthropic-messages">[] {
|
| 24 |
+
const models = getModels(provider) as Model<Api>[];
|
| 25 |
+
return models.filter((model) => model.api === "anthropic-messages") as Model<"anthropic-messages">[];
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const anthropicMessagesCases: AnthropicLongCacheRetentionE2ECase[] = getProviders().flatMap((provider) =>
|
| 29 |
+
getAnthropicMessagesModels(provider).map((model) => ({
|
| 30 |
+
name: `${provider}/${model.id}`,
|
| 31 |
+
provider,
|
| 32 |
+
model,
|
| 33 |
+
apiKey: getE2EApiKey(provider),
|
| 34 |
+
})),
|
| 35 |
+
);
|
| 36 |
+
|
| 37 |
+
function getProbePriority(model: Model<"anthropic-messages">): number {
|
| 38 |
+
const modelId = model.id.toLowerCase();
|
| 39 |
+
const cost = model.cost.input + model.cost.output;
|
| 40 |
+
let priority = cost;
|
| 41 |
+
|
| 42 |
+
if (modelId.includes("haiku") && (modelId.includes("4-5") || modelId.includes("4.5"))) {
|
| 43 |
+
priority -= 1000;
|
| 44 |
+
} else if (modelId.includes("sonnet") && (modelId.includes("4-") || modelId.includes("4."))) {
|
| 45 |
+
priority -= 750;
|
| 46 |
+
} else if (modelId.includes("claude") && (modelId.includes("4-") || modelId.includes("4."))) {
|
| 47 |
+
priority -= 500;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
return priority;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function selectOneCasePerProvider(cases: AnthropicLongCacheRetentionE2ECase[]): AnthropicLongCacheRetentionE2ECase[] {
|
| 54 |
+
const byProvider = new Map<BuiltinProvider, AnthropicLongCacheRetentionE2ECase[]>();
|
| 55 |
+
for (const testCase of cases) {
|
| 56 |
+
const providerCases = byProvider.get(testCase.provider) ?? [];
|
| 57 |
+
providerCases.push(testCase);
|
| 58 |
+
byProvider.set(testCase.provider, providerCases);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return Array.from(byProvider.values()).map(
|
| 62 |
+
(providerCases) =>
|
| 63 |
+
providerCases.sort(
|
| 64 |
+
(a, b) => getProbePriority(a.model) - getProbePriority(b.model) || a.model.id.localeCompare(b.model.id),
|
| 65 |
+
)[0],
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const probeCases = selectOneCasePerProvider(anthropicMessagesCases);
|
| 70 |
+
|
| 71 |
+
function withLongCacheRetention(model: Model<"anthropic-messages">): Model<"anthropic-messages"> {
|
| 72 |
+
return {
|
| 73 |
+
...model,
|
| 74 |
+
compat: {
|
| 75 |
+
...model.compat,
|
| 76 |
+
supportsLongCacheRetention: true,
|
| 77 |
+
},
|
| 78 |
+
};
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function expectLongCacheRetentionAccepted(
|
| 82 |
+
model: Model<"anthropic-messages">,
|
| 83 |
+
apiKey: string | undefined,
|
| 84 |
+
): Promise<void> {
|
| 85 |
+
const options: ProviderStreamOptions = {
|
| 86 |
+
apiKey,
|
| 87 |
+
cacheRetention: "long",
|
| 88 |
+
maxTokens: 128,
|
| 89 |
+
thinkingEnabled: false,
|
| 90 |
+
};
|
| 91 |
+
const response = await complete(
|
| 92 |
+
model,
|
| 93 |
+
{
|
| 94 |
+
systemPrompt: "You are a concise assistant.",
|
| 95 |
+
messages: [
|
| 96 |
+
{
|
| 97 |
+
role: "user",
|
| 98 |
+
content: "Reply with exactly: long cache retention accepted",
|
| 99 |
+
timestamp: Date.now(),
|
| 100 |
+
},
|
| 101 |
+
],
|
| 102 |
+
},
|
| 103 |
+
options,
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
expect(response.errorMessage, response.errorMessage).toBeFalsy();
|
| 107 |
+
expect(response.stopReason, response.errorMessage).not.toBe("error");
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
describe("Anthropic Messages long cache retention E2E", () => {
|
| 111 |
+
it("covers every generated anthropic-messages model", () => {
|
| 112 |
+
const expectedModels = getProviders().flatMap((provider) =>
|
| 113 |
+
getAnthropicMessagesModels(provider).map((model) => `${provider}/${model.id}`),
|
| 114 |
+
);
|
| 115 |
+
expect(anthropicMessagesCases.map((testCase) => testCase.name).sort()).toEqual(expectedModels.sort());
|
| 116 |
+
});
|
| 117 |
+
|
| 118 |
+
describe("forced long cache retention probe", () => {
|
| 119 |
+
for (const testCase of probeCases) {
|
| 120 |
+
const model = withLongCacheRetention(testCase.model);
|
| 121 |
+
|
| 122 |
+
it.skipIf(!testCase.apiKey)(`${testCase.name} accepts long cache retention`, { retry: 2 }, async () => {
|
| 123 |
+
await expectLongCacheRetentionAccepted(model, testCase.apiKey);
|
| 124 |
+
});
|
| 125 |
+
}
|
| 126 |
+
});
|
| 127 |
+
});
|
packages/ai/test/anthropic-mid-conversation-effort.test.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { stream } from "../src/api/anthropic-messages.ts";
|
| 3 |
+
import { getModel } from "../src/compat.ts";
|
| 4 |
+
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
interface WireMessage {
|
| 7 |
+
role: string;
|
| 8 |
+
content: unknown;
|
| 9 |
+
output_config?: { effort?: string };
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
interface CapturedPayload {
|
| 13 |
+
messages: WireMessage[];
|
| 14 |
+
thinking?: {
|
| 15 |
+
type: string;
|
| 16 |
+
display?: string;
|
| 17 |
+
block_binding?: { prefix_mismatch_behavior?: string };
|
| 18 |
+
};
|
| 19 |
+
output_config?: { effort?: string };
|
| 20 |
+
fallbacks?: Array<{ model: string }>;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function managedModel(provider = "anthropic"): Model<"anthropic-messages"> {
|
| 24 |
+
return {
|
| 25 |
+
id: "claude-fable-5-1",
|
| 26 |
+
name: "Claude Fable 5.1",
|
| 27 |
+
api: "anthropic-messages",
|
| 28 |
+
provider,
|
| 29 |
+
baseUrl: "http://127.0.0.1:9",
|
| 30 |
+
reasoning: true,
|
| 31 |
+
thinkingLevelMap: { off: null, minimal: "low", low: "low", medium: "medium", high: "high", max: "max" },
|
| 32 |
+
input: ["text"],
|
| 33 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 34 |
+
contextWindow: 200000,
|
| 35 |
+
maxTokens: 32000,
|
| 36 |
+
compat: { forceAdaptiveThinking: true, supportsMidConvoEffort: true },
|
| 37 |
+
};
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
function assistant(model: Model<"anthropic-messages">, level?: string): AssistantMessage {
|
| 41 |
+
return {
|
| 42 |
+
role: "assistant",
|
| 43 |
+
content: [
|
| 44 |
+
{ type: "thinking", thinking: "reasoning", thinkingSignature: "signature" },
|
| 45 |
+
{ type: "text", text: "answer" },
|
| 46 |
+
],
|
| 47 |
+
api: "anthropic-messages",
|
| 48 |
+
provider: model.provider,
|
| 49 |
+
model: model.id,
|
| 50 |
+
...(level === undefined ? {} : { providerThinkingLevel: level }),
|
| 51 |
+
usage: {
|
| 52 |
+
input: 0,
|
| 53 |
+
output: 0,
|
| 54 |
+
cacheRead: 0,
|
| 55 |
+
cacheWrite: 0,
|
| 56 |
+
totalTokens: 0,
|
| 57 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 58 |
+
},
|
| 59 |
+
stopReason: "stop",
|
| 60 |
+
timestamp: 1,
|
| 61 |
+
};
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
async function capture(
|
| 65 |
+
model: Model<"anthropic-messages">,
|
| 66 |
+
context: Context,
|
| 67 |
+
effort?: "low" | "medium" | "high" | "xhigh" | "max",
|
| 68 |
+
): Promise<{ payload: CapturedPayload; message: AssistantMessage }> {
|
| 69 |
+
let payload: CapturedPayload | undefined;
|
| 70 |
+
const result = stream(model, context, {
|
| 71 |
+
apiKey: "test-key",
|
| 72 |
+
cacheRetention: "none",
|
| 73 |
+
thinkingEnabled: true,
|
| 74 |
+
effort,
|
| 75 |
+
onPayload: (value) => {
|
| 76 |
+
payload = value as CapturedPayload;
|
| 77 |
+
throw new Error("payload captured");
|
| 78 |
+
},
|
| 79 |
+
});
|
| 80 |
+
const message = await result.result();
|
| 81 |
+
if (!payload) throw new Error("Expected payload capture");
|
| 82 |
+
return { payload, message };
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const user = (text: string, timestamp: number) => ({ role: "user" as const, content: text, timestamp });
|
| 86 |
+
|
| 87 |
+
function effortMessages(payload: CapturedPayload): WireMessage[] {
|
| 88 |
+
return payload.messages.filter((message) => message.role === "system");
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
describe("Anthropic mid-conversation effort", () => {
|
| 92 |
+
it("reconstructs an exact historical marker prefix and appends the current marker", async () => {
|
| 93 |
+
const model = managedModel();
|
| 94 |
+
const first = await capture(model, { messages: [user("one", 1)] }, "low");
|
| 95 |
+
const second = await capture(
|
| 96 |
+
model,
|
| 97 |
+
{ messages: [user("one", 1), assistant(model, "low"), user("two", 2)] },
|
| 98 |
+
"high",
|
| 99 |
+
);
|
| 100 |
+
|
| 101 |
+
expect(first.payload.messages).toEqual([
|
| 102 |
+
{ role: "user", content: "one" },
|
| 103 |
+
{ role: "system", content: [], output_config: { effort: "low" } },
|
| 104 |
+
]);
|
| 105 |
+
expect(second.payload.messages.slice(0, first.payload.messages.length)).toEqual(first.payload.messages);
|
| 106 |
+
expect(second.payload.messages.at(-1)).toEqual({
|
| 107 |
+
role: "system",
|
| 108 |
+
content: [],
|
| 109 |
+
output_config: { effort: "high" },
|
| 110 |
+
});
|
| 111 |
+
expect(first.payload.output_config).toEqual({ effort: "high" });
|
| 112 |
+
expect(second.payload.output_config).toEqual({ effort: "high" });
|
| 113 |
+
expect(second.payload.thinking).toEqual({
|
| 114 |
+
type: "adaptive",
|
| 115 |
+
display: "summarized",
|
| 116 |
+
block_binding: { prefix_mismatch_behavior: "drop_block" },
|
| 117 |
+
});
|
| 118 |
+
expect(first.message.providerThinkingLevel).toBe("low");
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
it.each(["low", "medium", "high", "xhigh", "max"] as const)("preserves native effort %s", async (effort) => {
|
| 122 |
+
const model = managedModel();
|
| 123 |
+
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, effort);
|
| 124 |
+
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort } }]);
|
| 125 |
+
expect(message.providerThinkingLevel).toBe(effort);
|
| 126 |
+
});
|
| 127 |
+
|
| 128 |
+
it("defaults omitted effort to high and still enables drop_block", async () => {
|
| 129 |
+
const { payload, message } = await capture(managedModel(), { messages: [user("one", 1)] });
|
| 130 |
+
expect(payload.messages.at(-1)).toEqual({
|
| 131 |
+
role: "system",
|
| 132 |
+
content: [],
|
| 133 |
+
output_config: { effort: "high" },
|
| 134 |
+
});
|
| 135 |
+
expect(payload.thinking?.block_binding?.prefix_mismatch_behavior).toBe("drop_block");
|
| 136 |
+
expect(message.providerThinkingLevel).toBe("high");
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
it("does not invent markers for legacy or other-provider assistants", async () => {
|
| 140 |
+
const model = managedModel();
|
| 141 |
+
const legacy = assistant(model);
|
| 142 |
+
const otherProvider = { ...assistant(model, "low"), provider: "other-provider" };
|
| 143 |
+
const { payload } = await capture(
|
| 144 |
+
model,
|
| 145 |
+
{ messages: [user("one", 1), legacy, user("two", 2), otherProvider, user("three", 3)] },
|
| 146 |
+
"medium",
|
| 147 |
+
);
|
| 148 |
+
expect(effortMessages(payload)).toEqual([{ role: "system", content: [], output_config: { effort: "medium" } }]);
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
it("leaves unsupported models on top-level effort", async () => {
|
| 152 |
+
const model = managedModel();
|
| 153 |
+
model.compat = { forceAdaptiveThinking: true };
|
| 154 |
+
const { payload, message } = await capture(model, { messages: [user("one", 1)] }, "low");
|
| 155 |
+
expect(payload.messages).toEqual([{ role: "user", content: "one" }]);
|
| 156 |
+
expect(payload.output_config).toEqual({ effort: "low" });
|
| 157 |
+
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 158 |
+
expect(message.providerThinkingLevel).toBeUndefined();
|
| 159 |
+
});
|
| 160 |
+
|
| 161 |
+
it("sends the effort and binding beta headers", async () => {
|
| 162 |
+
let betaHeader: string | null = null;
|
| 163 |
+
const events = [
|
| 164 |
+
{
|
| 165 |
+
type: "message_start",
|
| 166 |
+
message: {
|
| 167 |
+
id: "msg_test",
|
| 168 |
+
model: "claude-fable-5-1",
|
| 169 |
+
usage: { input_tokens: 1, output_tokens: 0 },
|
| 170 |
+
},
|
| 171 |
+
},
|
| 172 |
+
{
|
| 173 |
+
type: "message_delta",
|
| 174 |
+
delta: { stop_reason: "end_turn" },
|
| 175 |
+
usage: { input_tokens: 1, output_tokens: 1 },
|
| 176 |
+
},
|
| 177 |
+
{ type: "message_stop" },
|
| 178 |
+
];
|
| 179 |
+
const body = events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join("");
|
| 180 |
+
const fetchImpl: typeof fetch = async (input, init) => {
|
| 181 |
+
const request = input instanceof Request ? input : new Request(input, init);
|
| 182 |
+
betaHeader = request.headers.get("anthropic-beta");
|
| 183 |
+
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
|
| 184 |
+
};
|
| 185 |
+
const result = await stream(
|
| 186 |
+
managedModel(),
|
| 187 |
+
{ messages: [user("one", 1)] },
|
| 188 |
+
{
|
| 189 |
+
apiKey: "test-key",
|
| 190 |
+
cacheRetention: "none",
|
| 191 |
+
fetch: fetchImpl,
|
| 192 |
+
},
|
| 193 |
+
).result();
|
| 194 |
+
|
| 195 |
+
expect(result.stopReason).toBe("stop");
|
| 196 |
+
expect(betaHeader).toContain("mid-conversation-output-config-2026-07-01");
|
| 197 |
+
expect(betaHeader).toContain("thinking-binding-controls-2026-08-01");
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it("generates exact model and transport gates", () => {
|
| 201 |
+
const direct = getModel("anthropic", "claude-fable-5-1");
|
| 202 |
+
const openRouter = getModel("openrouter", "anthropic/claude-fable-5.1");
|
| 203 |
+
const unsupported = getModel("anthropic", "claude-opus-4-8");
|
| 204 |
+
expect(direct.compat?.supportsMidConvoEffort).toBe(true);
|
| 205 |
+
expect(direct.thinkingLevelMap?.off).toBeNull();
|
| 206 |
+
expect(openRouter.api).toBe("anthropic-messages");
|
| 207 |
+
expect(openRouter.baseUrl).toBe("https://openrouter.ai/api");
|
| 208 |
+
expect(openRouter.compat?.supportsMidConvoEffort).toBe(true);
|
| 209 |
+
expect(unsupported.compat?.supportsMidConvoEffort).toBeUndefined();
|
| 210 |
+
expect(getModel("anthropic", "claude-opus-5").compat?.allowedFallbackModels).toBeUndefined();
|
| 211 |
+
});
|
| 212 |
+
});
|
packages/ai/test/anthropic-oauth.test.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
| 3 |
+
import type { AuthEvent, AuthPrompt } from "../src/auth/types.ts";
|
| 4 |
+
|
| 5 |
+
const neverAbortedSignal = new AbortController().signal;
|
| 6 |
+
|
| 7 |
+
function jsonResponse(body: unknown, status: number = 200): Response {
|
| 8 |
+
return new Response(JSON.stringify(body), {
|
| 9 |
+
status,
|
| 10 |
+
headers: {
|
| 11 |
+
"Content-Type": "application/json",
|
| 12 |
+
},
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function getUrl(input: unknown): string {
|
| 17 |
+
if (typeof input === "string") {
|
| 18 |
+
return input;
|
| 19 |
+
}
|
| 20 |
+
if (input instanceof URL) {
|
| 21 |
+
return input.toString();
|
| 22 |
+
}
|
| 23 |
+
if (input instanceof Request) {
|
| 24 |
+
return input.url;
|
| 25 |
+
}
|
| 26 |
+
throw new Error(`Unsupported fetch input: ${String(input)}`);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function getJsonBody(init?: RequestInit): Record<string, string> {
|
| 30 |
+
if (typeof init?.body !== "string") {
|
| 31 |
+
throw new Error(`Expected string request body, got ${typeof init?.body}`);
|
| 32 |
+
}
|
| 33 |
+
return JSON.parse(init.body) as Record<string, string>;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
describe.sequential("Anthropic OAuth", () => {
|
| 37 |
+
afterEach(() => {
|
| 38 |
+
vi.unstubAllGlobals();
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it("keeps the localhost redirect_uri for manual callback login", async () => {
|
| 42 |
+
let authUrl = "";
|
| 43 |
+
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
| 44 |
+
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
| 45 |
+
expect(init?.method).toBe("POST");
|
| 46 |
+
const body = getJsonBody(init);
|
| 47 |
+
expect(body.grant_type).toBe("authorization_code");
|
| 48 |
+
expect(body.code).toBe("manual-code");
|
| 49 |
+
expect(body.redirect_uri).toBe("http://localhost:53692/callback");
|
| 50 |
+
return jsonResponse({
|
| 51 |
+
access_token: "access-token",
|
| 52 |
+
refresh_token: "refresh-token",
|
| 53 |
+
expires_in: 3600,
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 57 |
+
|
| 58 |
+
const credentials = await anthropicOAuth.login({
|
| 59 |
+
signal: neverAbortedSignal,
|
| 60 |
+
notify: (event) => {
|
| 61 |
+
if (event.type === "auth_url") authUrl = event.url;
|
| 62 |
+
},
|
| 63 |
+
prompt: async (prompt) => {
|
| 64 |
+
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
| 65 |
+
const url = new URL(authUrl);
|
| 66 |
+
const state = url.searchParams.get("state");
|
| 67 |
+
const redirectUri = url.searchParams.get("redirect_uri");
|
| 68 |
+
if (!state || !redirectUri) throw new Error("Missing OAuth state or redirect_uri in auth URL");
|
| 69 |
+
return `${redirectUri}?code=manual-code&state=${state}`;
|
| 70 |
+
},
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
expect(credentials.access).toBe("access-token");
|
| 74 |
+
expect(credentials.refresh).toBe("refresh-token");
|
| 75 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 76 |
+
});
|
| 77 |
+
|
| 78 |
+
it("omits scope from refresh token requests", async () => {
|
| 79 |
+
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise<Response> => {
|
| 80 |
+
expect(getUrl(input)).toBe("https://platform.claude.com/v1/oauth/token");
|
| 81 |
+
expect(init?.method).toBe("POST");
|
| 82 |
+
const body = getJsonBody(init);
|
| 83 |
+
expect(body.grant_type).toBe("refresh_token");
|
| 84 |
+
expect(body.client_id).toBeTruthy();
|
| 85 |
+
expect(body.refresh_token).toBe("refresh-token");
|
| 86 |
+
expect(body).not.toHaveProperty("scope");
|
| 87 |
+
return jsonResponse({
|
| 88 |
+
access_token: "new-access-token",
|
| 89 |
+
refresh_token: "new-refresh-token",
|
| 90 |
+
expires_in: 3600,
|
| 91 |
+
});
|
| 92 |
+
});
|
| 93 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 94 |
+
|
| 95 |
+
const credentials = await anthropicOAuth.refresh(
|
| 96 |
+
{
|
| 97 |
+
type: "oauth",
|
| 98 |
+
access: "old-access-token",
|
| 99 |
+
refresh: "refresh-token",
|
| 100 |
+
expires: 0,
|
| 101 |
+
},
|
| 102 |
+
neverAbortedSignal,
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
expect(credentials.access).toBe("new-access-token");
|
| 106 |
+
expect(credentials.refresh).toBe("new-refresh-token");
|
| 107 |
+
expect(fetchMock).toHaveBeenCalledOnce();
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("anthropicOAuth.login resolves through the manual_code prompt and aborts it after settling", async () => {
|
| 111 |
+
const fetchMock = vi.fn(async (input: unknown): Promise<Response> => {
|
| 112 |
+
const url = typeof input === "string" ? input : String(input);
|
| 113 |
+
if (url.includes("/oauth/token")) {
|
| 114 |
+
return jsonResponse({ access_token: "access", refresh_token: "refresh", expires_in: 3600 });
|
| 115 |
+
}
|
| 116 |
+
throw new Error(`Unexpected fetch: ${url}`);
|
| 117 |
+
});
|
| 118 |
+
vi.stubGlobal("fetch", fetchMock);
|
| 119 |
+
|
| 120 |
+
const events: AuthEvent[] = [];
|
| 121 |
+
const prompts: AuthPrompt[] = [];
|
| 122 |
+
let manualSignal: AbortSignal | undefined;
|
| 123 |
+
|
| 124 |
+
const credential = await anthropicOAuth.login({
|
| 125 |
+
signal: neverAbortedSignal,
|
| 126 |
+
notify: (event) => events.push(event),
|
| 127 |
+
prompt: async (prompt) => {
|
| 128 |
+
prompts.push(prompt);
|
| 129 |
+
if (prompt.type === "manual_code") {
|
| 130 |
+
manualSignal = prompt.signal;
|
| 131 |
+
return "the-code";
|
| 132 |
+
}
|
| 133 |
+
throw new Error(`Unexpected prompt: ${prompt.type}`);
|
| 134 |
+
},
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
expect(credential.type).toBe("oauth");
|
| 138 |
+
expect(credential.access).toBe("access");
|
| 139 |
+
expect(events.some((e) => e.type === "auth_url")).toBe(true);
|
| 140 |
+
expect(prompts.some((p) => p.type === "manual_code")).toBe(true);
|
| 141 |
+
// the prompt's signal is aborted once login settles, so UIs can dismiss it
|
| 142 |
+
expect(manualSignal?.aborted).toBe(true);
|
| 143 |
+
});
|
| 144 |
+
});
|
packages/ai/test/anthropic-opus-4-8-smoke.test.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { getModel, streamSimple } from "../src/compat.ts";
|
| 3 |
+
import type { Context } from "../src/types.ts";
|
| 4 |
+
|
| 5 |
+
interface AnthropicThinkingPayload {
|
| 6 |
+
thinking?: { type: string };
|
| 7 |
+
output_config?: { effort?: string };
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function makeContext(): Context {
|
| 11 |
+
return {
|
| 12 |
+
systemPrompt: "You are a precise assistant. Follow the user's instructions exactly.",
|
| 13 |
+
messages: [
|
| 14 |
+
{
|
| 15 |
+
role: "user",
|
| 16 |
+
content:
|
| 17 |
+
"Compute 48291 * 7317 and 90844 - 17729, add the results, and determine whether the sum is divisible by 11. Reply with exactly this format and nothing else: sum=<sum>; divisibleBy11=<yes|no>",
|
| 18 |
+
timestamp: Date.now(),
|
| 19 |
+
},
|
| 20 |
+
],
|
| 21 |
+
};
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Opus 4.8 smoke", () => {
|
| 25 |
+
it("streams Claude Opus 4.8 with reasoning enabled", { retry: 2, timeout: 30000 }, async () => {
|
| 26 |
+
const model = getModel("anthropic", "claude-opus-4-8");
|
| 27 |
+
let capturedPayload: AnthropicThinkingPayload | undefined;
|
| 28 |
+
const s = streamSimple(model, makeContext(), {
|
| 29 |
+
reasoning: "high",
|
| 30 |
+
maxTokens: 1024,
|
| 31 |
+
onPayload: (payload) => {
|
| 32 |
+
capturedPayload = payload as AnthropicThinkingPayload;
|
| 33 |
+
return payload;
|
| 34 |
+
},
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
let sawThinking = false;
|
| 38 |
+
|
| 39 |
+
for await (const event of s) {
|
| 40 |
+
if (event.type === "thinking_start" || event.type === "thinking_delta" || event.type === "thinking_end") {
|
| 41 |
+
sawThinking = true;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const response = await s.result();
|
| 46 |
+
expect(response.stopReason, response.errorMessage).toBe("stop");
|
| 47 |
+
expect(response.errorMessage).toBeFalsy();
|
| 48 |
+
expect(capturedPayload?.thinking).toEqual({ type: "adaptive" });
|
| 49 |
+
expect(capturedPayload?.output_config).toEqual({ effort: "high" });
|
| 50 |
+
expect(sawThinking).toBe(true);
|
| 51 |
+
|
| 52 |
+
const thinkingBlock = response.content.find((block) => block.type === "thinking");
|
| 53 |
+
expect(thinkingBlock?.type).toBe("thinking");
|
| 54 |
+
if (!thinkingBlock || thinkingBlock.type !== "thinking") {
|
| 55 |
+
throw new Error("Expected thinking block from Claude Opus 4.8");
|
| 56 |
+
}
|
| 57 |
+
expect(typeof thinkingBlock.thinkingSignature).toBe("string");
|
| 58 |
+
const thinkingSignature = thinkingBlock.thinkingSignature;
|
| 59 |
+
if (!thinkingSignature) {
|
| 60 |
+
throw new Error("Expected thinking signature from Claude Opus 4.8");
|
| 61 |
+
}
|
| 62 |
+
expect(thinkingSignature.length).toBeGreaterThan(0);
|
| 63 |
+
|
| 64 |
+
const text = response.content
|
| 65 |
+
.filter((block) => block.type === "text")
|
| 66 |
+
.map((block) => block.text)
|
| 67 |
+
.join("")
|
| 68 |
+
.trim();
|
| 69 |
+
expect(text).toBe("sum=353418362; divisibleBy11=yes");
|
| 70 |
+
});
|
| 71 |
+
});
|
packages/ai/test/anthropic-sse-parsing.test.ts
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type Anthropic from "@anthropic-ai/sdk";
|
| 2 |
+
import { Type } from "typebox";
|
| 3 |
+
import { describe, expect, it } from "vitest";
|
| 4 |
+
import { stream as streamAnthropic } from "../src/api/anthropic-messages.ts";
|
| 5 |
+
import { getModel } from "../src/compat.ts";
|
| 6 |
+
import type { Context, ToolCall } from "../src/types.ts";
|
| 7 |
+
|
| 8 |
+
function createSseResponse(events: Array<{ event: string; data: string }>): Response {
|
| 9 |
+
const body = events.map(({ event, data }) => `event: ${event}\ndata: ${data}\n`).join("\n");
|
| 10 |
+
return new Response(body, {
|
| 11 |
+
status: 200,
|
| 12 |
+
headers: { "content-type": "text/event-stream" },
|
| 13 |
+
});
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
const minimalAnthropicEvents = [
|
| 17 |
+
{
|
| 18 |
+
event: "message_start",
|
| 19 |
+
data: JSON.stringify({
|
| 20 |
+
type: "message_start",
|
| 21 |
+
message: {
|
| 22 |
+
id: "msg_test",
|
| 23 |
+
usage: {
|
| 24 |
+
input_tokens: 12,
|
| 25 |
+
output_tokens: 0,
|
| 26 |
+
cache_read_input_tokens: 0,
|
| 27 |
+
cache_creation_input_tokens: 0,
|
| 28 |
+
},
|
| 29 |
+
},
|
| 30 |
+
}),
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
event: "content_block_start",
|
| 34 |
+
data: JSON.stringify({
|
| 35 |
+
type: "content_block_start",
|
| 36 |
+
index: 0,
|
| 37 |
+
content_block: { type: "text", text: "" },
|
| 38 |
+
}),
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
event: "content_block_delta",
|
| 42 |
+
data: JSON.stringify({
|
| 43 |
+
type: "content_block_delta",
|
| 44 |
+
index: 0,
|
| 45 |
+
delta: { type: "text_delta", text: "Hello" },
|
| 46 |
+
}),
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
event: "content_block_stop",
|
| 50 |
+
data: JSON.stringify({ type: "content_block_stop", index: 0 }),
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
event: "message_delta",
|
| 54 |
+
data: JSON.stringify({
|
| 55 |
+
type: "message_delta",
|
| 56 |
+
delta: { stop_reason: "end_turn" },
|
| 57 |
+
usage: {
|
| 58 |
+
input_tokens: 12,
|
| 59 |
+
output_tokens: 5,
|
| 60 |
+
cache_read_input_tokens: 0,
|
| 61 |
+
cache_creation_input_tokens: 0,
|
| 62 |
+
},
|
| 63 |
+
}),
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
event: "message_stop",
|
| 67 |
+
data: JSON.stringify({ type: "message_stop" }),
|
| 68 |
+
},
|
| 69 |
+
];
|
| 70 |
+
|
| 71 |
+
function createFakeAnthropicClient(response: Response): Anthropic {
|
| 72 |
+
return {
|
| 73 |
+
beta: {
|
| 74 |
+
messages: {
|
| 75 |
+
create: () => ({
|
| 76 |
+
asResponse: async () => response,
|
| 77 |
+
}),
|
| 78 |
+
},
|
| 79 |
+
},
|
| 80 |
+
} as unknown as Anthropic;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
describe("Anthropic raw SSE parsing", () => {
|
| 84 |
+
it("fails safely when Anthropic falls back after output begins", async () => {
|
| 85 |
+
const model = getModel("anthropic", "claude-opus-5");
|
| 86 |
+
const response = createSseResponse([
|
| 87 |
+
{
|
| 88 |
+
event: "message_start",
|
| 89 |
+
data: JSON.stringify({
|
| 90 |
+
type: "message_start",
|
| 91 |
+
message: {
|
| 92 |
+
id: "msg_fallback",
|
| 93 |
+
model: "claude-opus-5",
|
| 94 |
+
usage: { input_tokens: 1, output_tokens: 0 },
|
| 95 |
+
},
|
| 96 |
+
}),
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
event: "content_block_start",
|
| 100 |
+
data: JSON.stringify({
|
| 101 |
+
type: "content_block_start",
|
| 102 |
+
index: 0,
|
| 103 |
+
content_block: { type: "text", text: "partial" },
|
| 104 |
+
}),
|
| 105 |
+
},
|
| 106 |
+
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
|
| 107 |
+
{
|
| 108 |
+
event: "content_block_start",
|
| 109 |
+
data: JSON.stringify({
|
| 110 |
+
type: "content_block_start",
|
| 111 |
+
index: 1,
|
| 112 |
+
content_block: {
|
| 113 |
+
type: "fallback",
|
| 114 |
+
from: { model: "claude-opus-5" },
|
| 115 |
+
to: { model: "claude-opus-4-8" },
|
| 116 |
+
},
|
| 117 |
+
}),
|
| 118 |
+
},
|
| 119 |
+
]);
|
| 120 |
+
|
| 121 |
+
const result = await streamAnthropic(
|
| 122 |
+
model,
|
| 123 |
+
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
| 124 |
+
{ client: createFakeAnthropicClient(response) },
|
| 125 |
+
).result();
|
| 126 |
+
|
| 127 |
+
expect(result.stopReason).toBe("error");
|
| 128 |
+
expect(result.errorMessage).toContain("unsupported mid-output model fallback");
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
it("forces streaming after an onPayload replacement", async () => {
|
| 132 |
+
let streaming: boolean | undefined;
|
| 133 |
+
const client = {
|
| 134 |
+
beta: {
|
| 135 |
+
messages: {
|
| 136 |
+
create: (params: { stream?: boolean }) => {
|
| 137 |
+
streaming = params.stream;
|
| 138 |
+
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
|
| 139 |
+
},
|
| 140 |
+
},
|
| 141 |
+
},
|
| 142 |
+
} as unknown as Anthropic;
|
| 143 |
+
|
| 144 |
+
await streamAnthropic(
|
| 145 |
+
getModel("anthropic", "claude-fable-5-1"),
|
| 146 |
+
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
| 147 |
+
{
|
| 148 |
+
client,
|
| 149 |
+
onPayload: (payload) => ({ ...(payload as Record<string, unknown>), stream: false }),
|
| 150 |
+
},
|
| 151 |
+
).result();
|
| 152 |
+
|
| 153 |
+
expect(streaming).toBe(true);
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
it("omits the interleaved-thinking beta when thinking is disabled", async () => {
|
| 157 |
+
let betaFeatures: string[] | undefined;
|
| 158 |
+
const client = {
|
| 159 |
+
beta: {
|
| 160 |
+
messages: {
|
| 161 |
+
create: (params: { betas?: string[] }) => {
|
| 162 |
+
betaFeatures = params.betas;
|
| 163 |
+
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
|
| 164 |
+
},
|
| 165 |
+
},
|
| 166 |
+
},
|
| 167 |
+
} as unknown as Anthropic;
|
| 168 |
+
|
| 169 |
+
await streamAnthropic(
|
| 170 |
+
getModel("openrouter", "anthropic/claude-3-haiku"),
|
| 171 |
+
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
| 172 |
+
{ client, thinkingEnabled: false },
|
| 173 |
+
).result();
|
| 174 |
+
|
| 175 |
+
expect(betaFeatures ?? []).not.toContain("interleaved-thinking-2025-05-14");
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
it("passes managed beta features to injected clients", async () => {
|
| 179 |
+
let betaFeatures: string[] | undefined;
|
| 180 |
+
const client = {
|
| 181 |
+
beta: {
|
| 182 |
+
messages: {
|
| 183 |
+
create: (params: { betas?: string[] }) => {
|
| 184 |
+
betaFeatures = params.betas;
|
| 185 |
+
return { asResponse: async () => createSseResponse(minimalAnthropicEvents) };
|
| 186 |
+
},
|
| 187 |
+
},
|
| 188 |
+
},
|
| 189 |
+
} as unknown as Anthropic;
|
| 190 |
+
|
| 191 |
+
const result = await streamAnthropic(
|
| 192 |
+
getModel("anthropic", "claude-fable-5-1"),
|
| 193 |
+
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
| 194 |
+
{ client },
|
| 195 |
+
).result();
|
| 196 |
+
|
| 197 |
+
expect(result.stopReason).toBe("stop");
|
| 198 |
+
expect(betaFeatures).toContain("mid-conversation-output-config-2026-07-01");
|
| 199 |
+
expect(betaFeatures).toContain("thinking-binding-controls-2026-08-01");
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
it("uses the serving model input transformations from the final stream event", async () => {
|
| 203 |
+
const events = minimalAnthropicEvents.map((event) => ({ ...event }));
|
| 204 |
+
events[0].data = JSON.stringify({
|
| 205 |
+
type: "message_start",
|
| 206 |
+
message: {
|
| 207 |
+
id: "msg_transformations",
|
| 208 |
+
model: "claude-fable-5-1",
|
| 209 |
+
usage: { input_tokens: 12, output_tokens: 0 },
|
| 210 |
+
input_transformations: [
|
| 211 |
+
{ type: "thinking_dropped", path: "messages.1.content.0", reason: "prefix_binding_mismatch" },
|
| 212 |
+
],
|
| 213 |
+
},
|
| 214 |
+
});
|
| 215 |
+
const delta = JSON.parse(events[4].data) as Record<string, unknown>;
|
| 216 |
+
delta.input_transformations = [
|
| 217 |
+
{ type: "thinking_dropped", path: "messages.3.content.0", reason: "model_binding_mismatch" },
|
| 218 |
+
];
|
| 219 |
+
events[4].data = JSON.stringify(delta);
|
| 220 |
+
|
| 221 |
+
const result = await streamAnthropic(
|
| 222 |
+
getModel("anthropic", "claude-fable-5-1"),
|
| 223 |
+
{ messages: [{ role: "user", content: "Hello", timestamp: 1 }] },
|
| 224 |
+
{ client: createFakeAnthropicClient(createSseResponse(events)) },
|
| 225 |
+
).result();
|
| 226 |
+
|
| 227 |
+
expect(result.diagnostics).toEqual([
|
| 228 |
+
{
|
| 229 |
+
type: "anthropic_input_transformations",
|
| 230 |
+
timestamp: expect.any(Number),
|
| 231 |
+
details: {
|
| 232 |
+
transformations: [
|
| 233 |
+
{
|
| 234 |
+
type: "thinking_dropped",
|
| 235 |
+
path: "messages.3.content.0",
|
| 236 |
+
reason: "model_binding_mismatch",
|
| 237 |
+
},
|
| 238 |
+
],
|
| 239 |
+
},
|
| 240 |
+
},
|
| 241 |
+
]);
|
| 242 |
+
});
|
| 243 |
+
it("repairs malformed SSE JSON and malformed streamed tool JSON", async () => {
|
| 244 |
+
const model = getModel("anthropic", "claude-haiku-4-5");
|
| 245 |
+
const context: Context = {
|
| 246 |
+
messages: [{ role: "user", content: "Use the edit tool.", timestamp: Date.now() }],
|
| 247 |
+
tools: [
|
| 248 |
+
{
|
| 249 |
+
name: "edit",
|
| 250 |
+
description: "Edit a file.",
|
| 251 |
+
parameters: Type.Object({
|
| 252 |
+
path: Type.String(),
|
| 253 |
+
text: Type.String(),
|
| 254 |
+
}),
|
| 255 |
+
},
|
| 256 |
+
],
|
| 257 |
+
};
|
| 258 |
+
|
| 259 |
+
const malformedToolJsonDelta = String.raw`{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":\"A\H\",\"text\":\"col1 col2\"}"}}`;
|
| 260 |
+
|
| 261 |
+
const response = createSseResponse([
|
| 262 |
+
{
|
| 263 |
+
event: "message_start",
|
| 264 |
+
data: JSON.stringify({
|
| 265 |
+
type: "message_start",
|
| 266 |
+
message: {
|
| 267 |
+
id: "msg_test",
|
| 268 |
+
usage: {
|
| 269 |
+
input_tokens: 12,
|
| 270 |
+
output_tokens: 0,
|
| 271 |
+
cache_read_input_tokens: 0,
|
| 272 |
+
cache_creation_input_tokens: 0,
|
| 273 |
+
},
|
| 274 |
+
},
|
| 275 |
+
}),
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
event: "content_block_start",
|
| 279 |
+
data: JSON.stringify({
|
| 280 |
+
type: "content_block_start",
|
| 281 |
+
index: 0,
|
| 282 |
+
content_block: {
|
| 283 |
+
type: "tool_use",
|
| 284 |
+
id: "toolu_test",
|
| 285 |
+
name: "edit",
|
| 286 |
+
input: {},
|
| 287 |
+
},
|
| 288 |
+
}),
|
| 289 |
+
},
|
| 290 |
+
{ event: "content_block_delta", data: malformedToolJsonDelta },
|
| 291 |
+
{
|
| 292 |
+
event: "content_block_stop",
|
| 293 |
+
data: JSON.stringify({ type: "content_block_stop", index: 0 }),
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
event: "message_delta",
|
| 297 |
+
data: JSON.stringify({
|
| 298 |
+
type: "message_delta",
|
| 299 |
+
delta: { stop_reason: "tool_use" },
|
| 300 |
+
usage: {
|
| 301 |
+
input_tokens: 12,
|
| 302 |
+
output_tokens: 5,
|
| 303 |
+
cache_read_input_tokens: 0,
|
| 304 |
+
cache_creation_input_tokens: 0,
|
| 305 |
+
},
|
| 306 |
+
}),
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
event: "message_stop",
|
| 310 |
+
data: JSON.stringify({ type: "message_stop" }),
|
| 311 |
+
},
|
| 312 |
+
]);
|
| 313 |
+
|
| 314 |
+
const stream = streamAnthropic(model, context, {
|
| 315 |
+
client: createFakeAnthropicClient(response),
|
| 316 |
+
});
|
| 317 |
+
const result = await stream.result();
|
| 318 |
+
|
| 319 |
+
expect(result.stopReason).toBe("toolUse");
|
| 320 |
+
expect(result.errorMessage).toBeUndefined();
|
| 321 |
+
|
| 322 |
+
const toolCall = result.content.find((block): block is ToolCall => block.type === "toolCall");
|
| 323 |
+
expect(toolCall).toBeDefined();
|
| 324 |
+
expect(toolCall?.arguments).toEqual({
|
| 325 |
+
path: "A\\H",
|
| 326 |
+
text: "col1\tcol2",
|
| 327 |
+
});
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
it("preserves content from content_block_start events", async () => {
|
| 331 |
+
const model = getModel("anthropic", "claude-haiku-4-5");
|
| 332 |
+
const context: Context = {
|
| 333 |
+
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
| 334 |
+
};
|
| 335 |
+
const response = createSseResponse([
|
| 336 |
+
{
|
| 337 |
+
event: "message_start",
|
| 338 |
+
data: JSON.stringify({
|
| 339 |
+
type: "message_start",
|
| 340 |
+
message: {
|
| 341 |
+
id: "msg_initial_content",
|
| 342 |
+
usage: {
|
| 343 |
+
input_tokens: 12,
|
| 344 |
+
output_tokens: 0,
|
| 345 |
+
cache_read_input_tokens: 0,
|
| 346 |
+
cache_creation_input_tokens: 0,
|
| 347 |
+
},
|
| 348 |
+
},
|
| 349 |
+
}),
|
| 350 |
+
},
|
| 351 |
+
{
|
| 352 |
+
event: "content_block_start",
|
| 353 |
+
data: JSON.stringify({
|
| 354 |
+
type: "content_block_start",
|
| 355 |
+
index: 0,
|
| 356 |
+
content_block: { type: "text", text: "Initial text" },
|
| 357 |
+
}),
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
event: "content_block_delta",
|
| 361 |
+
data: JSON.stringify({
|
| 362 |
+
type: "content_block_delta",
|
| 363 |
+
index: 0,
|
| 364 |
+
delta: { type: "text_delta", text: " plus delta" },
|
| 365 |
+
}),
|
| 366 |
+
},
|
| 367 |
+
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 0 }) },
|
| 368 |
+
{
|
| 369 |
+
event: "content_block_start",
|
| 370 |
+
data: JSON.stringify({
|
| 371 |
+
type: "content_block_start",
|
| 372 |
+
index: 1,
|
| 373 |
+
content_block: {
|
| 374 |
+
type: "thinking",
|
| 375 |
+
thinking: "Initial thinking",
|
| 376 |
+
signature: "initial signature",
|
| 377 |
+
},
|
| 378 |
+
}),
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
event: "content_block_delta",
|
| 382 |
+
data: JSON.stringify({
|
| 383 |
+
type: "content_block_delta",
|
| 384 |
+
index: 1,
|
| 385 |
+
delta: { type: "thinking_delta", thinking: " plus delta" },
|
| 386 |
+
}),
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
event: "content_block_delta",
|
| 390 |
+
data: JSON.stringify({
|
| 391 |
+
type: "content_block_delta",
|
| 392 |
+
index: 1,
|
| 393 |
+
delta: { type: "signature_delta", signature: " plus delta" },
|
| 394 |
+
}),
|
| 395 |
+
},
|
| 396 |
+
{ event: "content_block_stop", data: JSON.stringify({ type: "content_block_stop", index: 1 }) },
|
| 397 |
+
{
|
| 398 |
+
event: "message_delta",
|
| 399 |
+
data: JSON.stringify({
|
| 400 |
+
type: "message_delta",
|
| 401 |
+
delta: { stop_reason: "end_turn" },
|
| 402 |
+
usage: {
|
| 403 |
+
input_tokens: 12,
|
| 404 |
+
output_tokens: 5,
|
| 405 |
+
cache_read_input_tokens: 0,
|
| 406 |
+
cache_creation_input_tokens: 0,
|
| 407 |
+
},
|
| 408 |
+
}),
|
| 409 |
+
},
|
| 410 |
+
{ event: "message_stop", data: JSON.stringify({ type: "message_stop" }) },
|
| 411 |
+
]);
|
| 412 |
+
|
| 413 |
+
const stream = streamAnthropic(model, context, {
|
| 414 |
+
client: createFakeAnthropicClient(response),
|
| 415 |
+
});
|
| 416 |
+
const result = await stream.result();
|
| 417 |
+
|
| 418 |
+
expect(result.content).toEqual([
|
| 419 |
+
{ type: "text", text: "Initial text plus delta" },
|
| 420 |
+
{
|
| 421 |
+
type: "thinking",
|
| 422 |
+
thinking: "Initial thinking plus delta",
|
| 423 |
+
thinkingSignature: "initial signature plus delta",
|
| 424 |
+
},
|
| 425 |
+
]);
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
it("preserves refusal stop details from message_delta", async () => {
|
| 429 |
+
const model = getModel("anthropic", "claude-fable-5");
|
| 430 |
+
const context: Context = {
|
| 431 |
+
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
|
| 432 |
+
};
|
| 433 |
+
const explanation =
|
| 434 |
+
"This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, provide feedback, or request an exemption based on how you use Claude, visit our help center: https://support.claude.com/en/articles/14604842-real-time-cyber-safeguards-on-claude.";
|
| 435 |
+
const response = createSseResponse([
|
| 436 |
+
{
|
| 437 |
+
event: "message_start",
|
| 438 |
+
data: JSON.stringify({
|
| 439 |
+
type: "message_start",
|
| 440 |
+
message: {
|
| 441 |
+
id: "msg_01XFUDYJgAACzvnptvVoYEL",
|
| 442 |
+
usage: {
|
| 443 |
+
input_tokens: 412,
|
| 444 |
+
output_tokens: 0,
|
| 445 |
+
cache_read_input_tokens: 0,
|
| 446 |
+
cache_creation_input_tokens: 0,
|
| 447 |
+
},
|
| 448 |
+
},
|
| 449 |
+
}),
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
event: "message_delta",
|
| 453 |
+
data: JSON.stringify({
|
| 454 |
+
type: "message_delta",
|
| 455 |
+
delta: {
|
| 456 |
+
stop_reason: "refusal",
|
| 457 |
+
stop_details: {
|
| 458 |
+
type: "refusal",
|
| 459 |
+
category: "cyber",
|
| 460 |
+
explanation,
|
| 461 |
+
},
|
| 462 |
+
},
|
| 463 |
+
usage: {
|
| 464 |
+
input_tokens: 412,
|
| 465 |
+
output_tokens: 0,
|
| 466 |
+
cache_read_input_tokens: 0,
|
| 467 |
+
cache_creation_input_tokens: 0,
|
| 468 |
+
},
|
| 469 |
+
}),
|
| 470 |
+
},
|
| 471 |
+
{
|
| 472 |
+
event: "message_stop",
|
| 473 |
+
data: JSON.stringify({ type: "message_stop" }),
|
| 474 |
+
},
|
| 475 |
+
]);
|
| 476 |
+
|
| 477 |
+
const stream = streamAnthropic(model, context, {
|
| 478 |
+
client: createFakeAnthropicClient(response),
|
| 479 |
+
});
|
| 480 |
+
const result = await stream.result();
|
| 481 |
+
|
| 482 |
+
expect(result.stopReason).toBe("error");
|
| 483 |
+
expect(result.rawStopReason).toBe("refusal");
|
| 484 |
+
expect(result.errorMessage).toBe(explanation);
|
| 485 |
+
});
|
| 486 |
+
|
| 487 |
+
it("preserves sensitive stop reasons with a descriptive error message", async () => {
|
| 488 |
+
const model = getModel("anthropic", "claude-haiku-4-5");
|
| 489 |
+
const context: Context = {
|
| 490 |
+
messages: [{ role: "user", content: "blocked request", timestamp: Date.now() }],
|
| 491 |
+
};
|
| 492 |
+
const response = createSseResponse([
|
| 493 |
+
{
|
| 494 |
+
event: "message_start",
|
| 495 |
+
data: JSON.stringify({
|
| 496 |
+
type: "message_start",
|
| 497 |
+
message: {
|
| 498 |
+
id: "msg_sensitive",
|
| 499 |
+
usage: {
|
| 500 |
+
input_tokens: 12,
|
| 501 |
+
output_tokens: 0,
|
| 502 |
+
cache_read_input_tokens: 0,
|
| 503 |
+
cache_creation_input_tokens: 0,
|
| 504 |
+
},
|
| 505 |
+
},
|
| 506 |
+
}),
|
| 507 |
+
},
|
| 508 |
+
{
|
| 509 |
+
event: "message_delta",
|
| 510 |
+
data: JSON.stringify({
|
| 511 |
+
type: "message_delta",
|
| 512 |
+
delta: { stop_reason: "sensitive" },
|
| 513 |
+
usage: {
|
| 514 |
+
input_tokens: 12,
|
| 515 |
+
output_tokens: 0,
|
| 516 |
+
cache_read_input_tokens: 0,
|
| 517 |
+
cache_creation_input_tokens: 0,
|
| 518 |
+
},
|
| 519 |
+
}),
|
| 520 |
+
},
|
| 521 |
+
{
|
| 522 |
+
event: "message_stop",
|
| 523 |
+
data: JSON.stringify({ type: "message_stop" }),
|
| 524 |
+
},
|
| 525 |
+
]);
|
| 526 |
+
|
| 527 |
+
const stream = streamAnthropic(model, context, {
|
| 528 |
+
client: createFakeAnthropicClient(response),
|
| 529 |
+
});
|
| 530 |
+
const result = await stream.result();
|
| 531 |
+
|
| 532 |
+
expect(result.stopReason).toBe("error");
|
| 533 |
+
expect(result.rawStopReason).toBe("sensitive");
|
| 534 |
+
expect(result.errorMessage).toBe("Provider stopped with: sensitive");
|
| 535 |
+
});
|
| 536 |
+
|
| 537 |
+
it("treats message_delta without usage as a no-op for usage accumulation", async () => {
|
| 538 |
+
const model = getModel("anthropic", "claude-haiku-4-5");
|
| 539 |
+
const context: Context = {
|
| 540 |
+
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
| 541 |
+
};
|
| 542 |
+
const response = createSseResponse(
|
| 543 |
+
minimalAnthropicEvents.map((event) =>
|
| 544 |
+
event.event === "message_delta"
|
| 545 |
+
? {
|
| 546 |
+
event: "message_delta",
|
| 547 |
+
data: JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" } }),
|
| 548 |
+
}
|
| 549 |
+
: event,
|
| 550 |
+
),
|
| 551 |
+
);
|
| 552 |
+
|
| 553 |
+
const stream = streamAnthropic(model, context, {
|
| 554 |
+
client: createFakeAnthropicClient(response),
|
| 555 |
+
});
|
| 556 |
+
const result = await stream.result();
|
| 557 |
+
|
| 558 |
+
expect(result.stopReason).toBe("stop");
|
| 559 |
+
expect(result.errorMessage).toBeUndefined();
|
| 560 |
+
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
|
| 561 |
+
expect(result.usage.input).toBe(12);
|
| 562 |
+
expect(result.usage.totalTokens).toBe(12);
|
| 563 |
+
});
|
| 564 |
+
|
| 565 |
+
it("ignores unknown SSE events after message_stop", async () => {
|
| 566 |
+
const model = getModel("anthropic", "claude-haiku-4-5");
|
| 567 |
+
const context: Context = {
|
| 568 |
+
messages: [{ role: "user", content: "Say hello.", timestamp: Date.now() }],
|
| 569 |
+
};
|
| 570 |
+
const response = createSseResponse([
|
| 571 |
+
...minimalAnthropicEvents,
|
| 572 |
+
{ event: "done", data: "[DONE]" },
|
| 573 |
+
{ event: "proxy.stats", data: "not json" },
|
| 574 |
+
]);
|
| 575 |
+
|
| 576 |
+
const stream = streamAnthropic(model, context, {
|
| 577 |
+
client: createFakeAnthropicClient(response),
|
| 578 |
+
});
|
| 579 |
+
const result = await stream.result();
|
| 580 |
+
|
| 581 |
+
expect(result.stopReason).toBe("stop");
|
| 582 |
+
expect(result.errorMessage).toBeUndefined();
|
| 583 |
+
expect(result.content).toEqual([{ type: "text", text: "Hello" }]);
|
| 584 |
+
});
|
| 585 |
+
});
|
packages/ai/test/anthropic-thinking-binding-e2e.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { stream } from "../src/api/anthropic-messages.ts";
|
| 3 |
+
import { getModel } from "../src/compat.ts";
|
| 4 |
+
import type { AssistantMessage, Context } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
const enabled = Boolean(process.env.ANTHROPIC_API_KEY);
|
| 7 |
+
const model = getModel("anthropic", "claude-fable-5-1");
|
| 8 |
+
const user = (content: string, timestamp: number) => ({ role: "user" as const, content, timestamp });
|
| 9 |
+
|
| 10 |
+
function strictBinding(payload: unknown): unknown {
|
| 11 |
+
const params = payload as {
|
| 12 |
+
thinking?: {
|
| 13 |
+
block_binding?: { prefix_mismatch_behavior?: "drop_block" | "error" };
|
| 14 |
+
};
|
| 15 |
+
};
|
| 16 |
+
if (params.thinking?.block_binding) {
|
| 17 |
+
params.thinking.block_binding.prefix_mismatch_behavior = "error";
|
| 18 |
+
}
|
| 19 |
+
return params;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
async function request(context: Context, effort: "low" | "high"): Promise<AssistantMessage> {
|
| 23 |
+
return stream(model, context, {
|
| 24 |
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
| 25 |
+
cacheRetention: "none",
|
| 26 |
+
maxTokens: 1536,
|
| 27 |
+
thinkingEnabled: true,
|
| 28 |
+
thinkingDisplay: "summarized",
|
| 29 |
+
effort,
|
| 30 |
+
onPayload: strictBinding,
|
| 31 |
+
}).result();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
describe.skipIf(!enabled)("Anthropic thinking binding conformance", () => {
|
| 35 |
+
it("replays managed effort markers required by signed Fable thinking", { timeout: 120000 }, async () => {
|
| 36 |
+
const firstUser = user("Compute 982451653 multiplied by 961748941. Return only the integer.", 1);
|
| 37 |
+
const first = await request({ messages: [firstUser] }, "low");
|
| 38 |
+
expect(first.stopReason, first.errorMessage).toBe("stop");
|
| 39 |
+
expect(
|
| 40 |
+
first.content.some(
|
| 41 |
+
(block) =>
|
| 42 |
+
block.type === "thinking" &&
|
| 43 |
+
typeof block.thinkingSignature === "string" &&
|
| 44 |
+
block.thinkingSignature.length > 0,
|
| 45 |
+
),
|
| 46 |
+
).toBe(true);
|
| 47 |
+
expect(first.providerThinkingLevel).toBe("low");
|
| 48 |
+
|
| 49 |
+
const secondUser = user("Reply with exactly: ok", 2);
|
| 50 |
+
const exact = await request({ messages: [firstUser, first, secondUser] }, "high");
|
| 51 |
+
expect(exact.stopReason, exact.errorMessage).toBe("stop");
|
| 52 |
+
|
| 53 |
+
const unmanagedHistory: AssistantMessage = { ...first };
|
| 54 |
+
delete unmanagedHistory.providerThinkingLevel;
|
| 55 |
+
const missingMarker = await request({ messages: [firstUser, unmanagedHistory, secondUser] }, "high");
|
| 56 |
+
expect(missingMarker.stopReason).toBe("error");
|
| 57 |
+
expect(missingMarker.errorMessage).toContain("Invalid `signature`");
|
| 58 |
+
});
|
| 59 |
+
});
|
packages/ai/test/anthropic-thinking-disable.test.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { getModel, streamSimple } from "../src/compat.ts";
|
| 3 |
+
import type { Context, Model, SimpleStreamOptions } from "../src/types.ts";
|
| 4 |
+
|
| 5 |
+
interface AnthropicThinkingPayload {
|
| 6 |
+
thinking?: { type: string; budget_tokens?: number; display?: string };
|
| 7 |
+
output_config?: { effort?: string };
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
class PayloadCaptured extends Error {
|
| 11 |
+
constructor() {
|
| 12 |
+
super("payload captured");
|
| 13 |
+
this.name = "PayloadCaptured";
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
function makePayloadCaptureContext(): Context {
|
| 18 |
+
return {
|
| 19 |
+
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
| 20 |
+
};
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
async function capturePayload(
|
| 24 |
+
model: Model<"anthropic-messages">,
|
| 25 |
+
options?: SimpleStreamOptions,
|
| 26 |
+
): Promise<AnthropicThinkingPayload> {
|
| 27 |
+
let capturedPayload: AnthropicThinkingPayload | undefined;
|
| 28 |
+
const payloadCaptureModel: Model<"anthropic-messages"> = {
|
| 29 |
+
...model,
|
| 30 |
+
baseUrl: "http://127.0.0.1:9",
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const s = streamSimple(payloadCaptureModel, makePayloadCaptureContext(), {
|
| 34 |
+
...options,
|
| 35 |
+
apiKey: "fake-key",
|
| 36 |
+
onPayload: (payload) => {
|
| 37 |
+
capturedPayload = payload as AnthropicThinkingPayload;
|
| 38 |
+
throw new PayloadCaptured();
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
await s.result();
|
| 43 |
+
|
| 44 |
+
if (!capturedPayload) {
|
| 45 |
+
throw new Error("Expected payload to be captured before request failure");
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
return capturedPayload;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
interface RunResult {
|
| 52 |
+
thinkingEventCount: number;
|
| 53 |
+
thinkingCharCount: number;
|
| 54 |
+
text: string;
|
| 55 |
+
contentTypes: string[];
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function makeE2EContext(): Context {
|
| 59 |
+
return {
|
| 60 |
+
systemPrompt: "You are a precise assistant. Follow the requested output format exactly.",
|
| 61 |
+
messages: [
|
| 62 |
+
{
|
| 63 |
+
role: "user",
|
| 64 |
+
content:
|
| 65 |
+
"Before replying, carefully solve 36863 * 5279 internally. Then reply with the word pong repeated exactly 40 times, separated by single spaces. Do not add any other text.",
|
| 66 |
+
timestamp: Date.now(),
|
| 67 |
+
},
|
| 68 |
+
],
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function countPongs(text: string): number {
|
| 73 |
+
return text.match(/\bpong\b/gi)?.length ?? 0;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
async function runWithoutReasoning(model: Model<"anthropic-messages">): Promise<RunResult> {
|
| 77 |
+
const s = streamSimple(model, makeE2EContext(), {
|
| 78 |
+
temperature: 0,
|
| 79 |
+
maxTokens: 160,
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
let thinkingEventCount = 0;
|
| 83 |
+
let thinkingCharCount = 0;
|
| 84 |
+
|
| 85 |
+
for await (const event of s) {
|
| 86 |
+
if (event.type === "thinking_start" || event.type === "thinking_end") {
|
| 87 |
+
thinkingEventCount += 1;
|
| 88 |
+
}
|
| 89 |
+
if (event.type === "thinking_delta") {
|
| 90 |
+
thinkingEventCount += 1;
|
| 91 |
+
thinkingCharCount += event.delta.length;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
const response = await s.result();
|
| 96 |
+
expect(response.stopReason, response.errorMessage).toBe("stop");
|
| 97 |
+
|
| 98 |
+
const text = response.content
|
| 99 |
+
.filter((block) => block.type === "text")
|
| 100 |
+
.map((block) => block.text)
|
| 101 |
+
.join("")
|
| 102 |
+
.trim();
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
thinkingEventCount,
|
| 106 |
+
thinkingCharCount,
|
| 107 |
+
text,
|
| 108 |
+
contentTypes: response.content.map((block) => block.type),
|
| 109 |
+
};
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
describe("Anthropic thinking disable payload", () => {
|
| 113 |
+
it("sends thinking.type=disabled for budget-based reasoning models when thinking is off", async () => {
|
| 114 |
+
const payload = await capturePayload(getModel("anthropic", "claude-sonnet-4-5"));
|
| 115 |
+
|
| 116 |
+
expect(payload.thinking).toEqual({ type: "disabled" });
|
| 117 |
+
expect(payload.output_config).toBeUndefined();
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("sends thinking.type=disabled for adaptive reasoning models when thinking is off", async () => {
|
| 121 |
+
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-6"));
|
| 122 |
+
|
| 123 |
+
expect(payload.thinking).toEqual({ type: "disabled" });
|
| 124 |
+
expect(payload.output_config).toBeUndefined();
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
it("sends thinking.type=disabled for Claude Opus 4.8 when thinking is off", async () => {
|
| 128 |
+
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"));
|
| 129 |
+
|
| 130 |
+
expect(payload.thinking).toEqual({ type: "disabled" });
|
| 131 |
+
expect(payload.output_config).toBeUndefined();
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it("omits thinking.type=disabled for Claude Fable 5 when thinking is off", async () => {
|
| 135 |
+
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"));
|
| 136 |
+
|
| 137 |
+
expect(payload.thinking).toBeUndefined();
|
| 138 |
+
expect(payload.output_config).toBeUndefined();
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
+
it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => {
|
| 142 |
+
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "high" });
|
| 143 |
+
|
| 144 |
+
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 145 |
+
expect(payload.output_config).toEqual({ effort: "high" });
|
| 146 |
+
});
|
| 147 |
+
|
| 148 |
+
it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => {
|
| 149 |
+
const payload = await capturePayload(getModel("anthropic", "claude-sonnet-5"), { reasoning: "high" });
|
| 150 |
+
|
| 151 |
+
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 152 |
+
expect(payload.output_config).toEqual({ effort: "high" });
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => {
|
| 156 |
+
const payload = await capturePayload(getModel("anthropic", "claude-opus-4-8"), { reasoning: "xhigh" });
|
| 157 |
+
|
| 158 |
+
expect(payload.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 159 |
+
expect(payload.output_config).toEqual({ effort: "xhigh" });
|
| 160 |
+
});
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic thinking disable E2E", () => {
|
| 164 |
+
it("disables thinking for Claude reasoning models", { retry: 2, timeout: 30000 }, async () => {
|
| 165 |
+
const result = await runWithoutReasoning(getModel("anthropic", "claude-sonnet-4-5"));
|
| 166 |
+
|
| 167 |
+
expect(result.thinkingEventCount).toBe(0);
|
| 168 |
+
expect(result.thinkingCharCount).toBe(0);
|
| 169 |
+
expect(result.contentTypes).not.toContain("thinking");
|
| 170 |
+
expect(countPongs(result.text)).toBeGreaterThanOrEqual(35);
|
| 171 |
+
});
|
| 172 |
+
});
|
packages/ai/test/assistant-message-frame.test.ts
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ResponseStreamEvent } from "openai/resources/responses/responses.js";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { processResponsesStream } from "../src/api/openai-responses-shared.ts";
|
| 4 |
+
import {
|
| 5 |
+
type AssistantMessage,
|
| 6 |
+
type AssistantMessageEvent,
|
| 7 |
+
type AssistantMessageFrame,
|
| 8 |
+
AssistantMessageFrameEncoder,
|
| 9 |
+
type Model,
|
| 10 |
+
reduceAssistantMessageFrames,
|
| 11 |
+
} from "../src/index.ts";
|
| 12 |
+
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
| 13 |
+
|
| 14 |
+
function seed(): AssistantMessage {
|
| 15 |
+
return {
|
| 16 |
+
role: "assistant",
|
| 17 |
+
content: [],
|
| 18 |
+
api: "test-api",
|
| 19 |
+
provider: "test-provider",
|
| 20 |
+
model: "test-model",
|
| 21 |
+
usage: {
|
| 22 |
+
input: 0,
|
| 23 |
+
output: 0,
|
| 24 |
+
cacheRead: 0,
|
| 25 |
+
cacheWrite: 0,
|
| 26 |
+
totalTokens: 0,
|
| 27 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 28 |
+
},
|
| 29 |
+
stopReason: "pending",
|
| 30 |
+
timestamp: 1,
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function frame(encoder: AssistantMessageFrameEncoder, event: AssistantMessageEvent): AssistantMessageFrame {
|
| 35 |
+
const converted = encoder.encode(event);
|
| 36 |
+
if (!converted) throw new Error(`Expected ${event.type} event to produce a frame`);
|
| 37 |
+
return converted;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
describe("assistant message frames", () => {
|
| 41 |
+
it("uses authoritative text end content and signature", () => {
|
| 42 |
+
const partial = seed();
|
| 43 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 44 |
+
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
|
| 45 |
+
partial.content.push({ type: "text", text: "Hello " });
|
| 46 |
+
frames.push(frame(encoder, { type: "text_start", contentIndex: 0, partial }));
|
| 47 |
+
partial.content[0] = { type: "text", text: "Hello world", textSignature: "sig-text" };
|
| 48 |
+
frames.push(
|
| 49 |
+
frame(encoder, { type: "text_delta", contentIndex: 0, delta: "incorrect", partial }),
|
| 50 |
+
frame(encoder, { type: "text_end", contentIndex: 0, content: "Hello world", partial }),
|
| 51 |
+
);
|
| 52 |
+
|
| 53 |
+
expect(frames.at(-1)).toEqual({
|
| 54 |
+
type: "text_end",
|
| 55 |
+
contentIndex: 0,
|
| 56 |
+
content: "Hello world",
|
| 57 |
+
textSignature: "sig-text",
|
| 58 |
+
});
|
| 59 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
|
| 60 |
+
{ type: "text", text: "Hello world", textSignature: "sig-text" },
|
| 61 |
+
]);
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
it("preserves provider thinking level from the stream start", () => {
|
| 65 |
+
const partial = seed();
|
| 66 |
+
partial.providerThinkingLevel = "high";
|
| 67 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 68 |
+
const start = frame(encoder, { type: "start", partial });
|
| 69 |
+
|
| 70 |
+
expect(start).toMatchObject({ type: "start", partial: { providerThinkingLevel: "high" } });
|
| 71 |
+
expect(reduceAssistantMessageFrames([start])?.providerThinkingLevel).toBe("high");
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
it("preserves initial and final thinking metadata, including redaction", () => {
|
| 75 |
+
const partial = seed();
|
| 76 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 77 |
+
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
|
| 78 |
+
partial.content.push({
|
| 79 |
+
type: "thinking",
|
| 80 |
+
thinking: "[redacted]",
|
| 81 |
+
thinkingSignature: "encrypted-start",
|
| 82 |
+
redacted: true,
|
| 83 |
+
});
|
| 84 |
+
frames.push(frame(encoder, { type: "thinking_start", contentIndex: 0, partial }));
|
| 85 |
+
partial.content[0] = {
|
| 86 |
+
type: "thinking",
|
| 87 |
+
thinking: "[redacted]",
|
| 88 |
+
thinkingSignature: "encrypted-final",
|
| 89 |
+
redacted: true,
|
| 90 |
+
};
|
| 91 |
+
frames.push(frame(encoder, { type: "thinking_end", contentIndex: 0, content: "[redacted]", partial }));
|
| 92 |
+
|
| 93 |
+
expect(frames.at(-1)).toEqual({
|
| 94 |
+
type: "thinking_end",
|
| 95 |
+
contentIndex: 0,
|
| 96 |
+
content: "[redacted]",
|
| 97 |
+
thinkingSignature: "encrypted-final",
|
| 98 |
+
redacted: true,
|
| 99 |
+
});
|
| 100 |
+
expect(reduceAssistantMessageFrames(frames)?.content[0]).toEqual({
|
| 101 |
+
type: "thinking",
|
| 102 |
+
thinking: "[redacted]",
|
| 103 |
+
thinkingSignature: "encrypted-final",
|
| 104 |
+
redacted: true,
|
| 105 |
+
});
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
it("parses unfinished tool JSON once and uses authoritative completed arguments", () => {
|
| 109 |
+
const initialFrames: AssistantMessageFrame[] = [
|
| 110 |
+
{ type: "start", partial: seed() },
|
| 111 |
+
{
|
| 112 |
+
type: "toolcall_start",
|
| 113 |
+
contentIndex: 0,
|
| 114 |
+
toolCall: { type: "toolCall", id: "initial-id", name: "write", arguments: {} },
|
| 115 |
+
},
|
| 116 |
+
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path":"READ' },
|
| 117 |
+
];
|
| 118 |
+
|
| 119 |
+
expect(reduceAssistantMessageFrames(initialFrames)?.content[0]).toMatchObject({
|
| 120 |
+
type: "toolCall",
|
| 121 |
+
arguments: { path: "READ" },
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
const completeFrames: AssistantMessageFrame[] = [
|
| 125 |
+
...initialFrames,
|
| 126 |
+
{ type: "toolcall_delta", contentIndex: 0, delta: 'ME.md","lines":[1,2]}' },
|
| 127 |
+
{
|
| 128 |
+
type: "toolcall_end",
|
| 129 |
+
contentIndex: 0,
|
| 130 |
+
id: "final-id",
|
| 131 |
+
name: "write_file",
|
| 132 |
+
arguments: { path: "final.md", lines: [3] },
|
| 133 |
+
thoughtSignature: "thought",
|
| 134 |
+
namespace: "files",
|
| 135 |
+
},
|
| 136 |
+
];
|
| 137 |
+
expect(reduceAssistantMessageFrames(completeFrames)?.content[0]).toEqual({
|
| 138 |
+
type: "toolCall",
|
| 139 |
+
id: "final-id",
|
| 140 |
+
name: "write_file",
|
| 141 |
+
arguments: { path: "final.md", lines: [3] },
|
| 142 |
+
thoughtSignature: "thought",
|
| 143 |
+
namespace: "files",
|
| 144 |
+
});
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
it("round-trips OpenAI Responses content supplied only by authoritative end events", async () => {
|
| 148 |
+
const output = seed();
|
| 149 |
+
output.api = "openai-responses";
|
| 150 |
+
output.provider = "openai";
|
| 151 |
+
const model: Model<"openai-responses"> = {
|
| 152 |
+
id: output.model,
|
| 153 |
+
name: "Test",
|
| 154 |
+
api: "openai-responses",
|
| 155 |
+
provider: "openai",
|
| 156 |
+
baseUrl: "https://api.openai.com/v1",
|
| 157 |
+
reasoning: false,
|
| 158 |
+
input: ["text"],
|
| 159 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 160 |
+
contextWindow: 1000,
|
| 161 |
+
maxTokens: 100,
|
| 162 |
+
};
|
| 163 |
+
const events: ResponseStreamEvent[] = [
|
| 164 |
+
{
|
| 165 |
+
type: "response.output_item.added",
|
| 166 |
+
sequence_number: 0,
|
| 167 |
+
output_index: 0,
|
| 168 |
+
item: { type: "message", id: "msg", role: "assistant", status: "in_progress", content: [] },
|
| 169 |
+
} as ResponseStreamEvent,
|
| 170 |
+
{
|
| 171 |
+
type: "response.output_item.done",
|
| 172 |
+
sequence_number: 1,
|
| 173 |
+
output_index: 0,
|
| 174 |
+
item: {
|
| 175 |
+
type: "message",
|
| 176 |
+
id: "msg",
|
| 177 |
+
role: "assistant",
|
| 178 |
+
status: "completed",
|
| 179 |
+
content: [{ type: "output_text", text: "final text", annotations: [] }],
|
| 180 |
+
},
|
| 181 |
+
} as ResponseStreamEvent,
|
| 182 |
+
{
|
| 183 |
+
type: "response.output_item.added",
|
| 184 |
+
sequence_number: 2,
|
| 185 |
+
output_index: 1,
|
| 186 |
+
item: {
|
| 187 |
+
type: "function_call",
|
| 188 |
+
id: "fc",
|
| 189 |
+
call_id: "call",
|
| 190 |
+
name: "lookup",
|
| 191 |
+
arguments: "",
|
| 192 |
+
},
|
| 193 |
+
} as ResponseStreamEvent,
|
| 194 |
+
{
|
| 195 |
+
type: "response.output_item.done",
|
| 196 |
+
sequence_number: 3,
|
| 197 |
+
output_index: 1,
|
| 198 |
+
item: {
|
| 199 |
+
type: "function_call",
|
| 200 |
+
id: "fc",
|
| 201 |
+
call_id: "call",
|
| 202 |
+
name: "lookup",
|
| 203 |
+
arguments: '{"query":"pi"}',
|
| 204 |
+
},
|
| 205 |
+
} as ResponseStreamEvent,
|
| 206 |
+
{
|
| 207 |
+
type: "response.completed",
|
| 208 |
+
sequence_number: 4,
|
| 209 |
+
response: { id: "response", status: "completed", output: [] },
|
| 210 |
+
} as unknown as ResponseStreamEvent,
|
| 211 |
+
];
|
| 212 |
+
async function* source(): AsyncGenerator<ResponseStreamEvent> {
|
| 213 |
+
for (const event of events) yield event;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 217 |
+
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial: output })];
|
| 218 |
+
const stream = new AssistantMessageEventStream();
|
| 219 |
+
const push = stream.push.bind(stream);
|
| 220 |
+
stream.push = (event) => {
|
| 221 |
+
const converted = encoder.encode(event);
|
| 222 |
+
if (converted) frames.push(converted);
|
| 223 |
+
push(event);
|
| 224 |
+
};
|
| 225 |
+
await processResponsesStream(source(), output, stream, model);
|
| 226 |
+
|
| 227 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual(output.content);
|
| 228 |
+
});
|
| 229 |
+
|
| 230 |
+
it("reconciles queued text events against one advanced live partial without duplicate content", () => {
|
| 231 |
+
const partial = seed();
|
| 232 |
+
const events: AssistantMessageEvent[] = [{ type: "start", partial }];
|
| 233 |
+
const text = { type: "text" as const, text: "" };
|
| 234 |
+
partial.content.push(text);
|
| 235 |
+
events.push({ type: "text_start", contentIndex: 0, partial });
|
| 236 |
+
for (const delta of ["Hel", "lo", " ", "world"]) {
|
| 237 |
+
text.text += delta;
|
| 238 |
+
events.push({ type: "text_delta", contentIndex: 0, delta, partial });
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 242 |
+
const frames = events.flatMap((event) => {
|
| 243 |
+
const encoded = encoder.encode(event);
|
| 244 |
+
return encoded === undefined ? [] : [encoded];
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
expect(frames.map((item) => item.type)).toEqual(["start", "text_start"]);
|
| 248 |
+
expect(frames[0]).toMatchObject({ type: "start", partial: { content: [], stopReason: "pending" } });
|
| 249 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([{ type: "text", text: "Hello world" }]);
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
it("trims only the covered prefix when a start snapshot lands inside a delta", () => {
|
| 253 |
+
const partial = seed();
|
| 254 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 255 |
+
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
|
| 256 |
+
const text = { type: "text" as const, text: "Hel" };
|
| 257 |
+
partial.content.push(text);
|
| 258 |
+
frames.push(frame(encoder, { type: "text_start", contentIndex: 0, partial }));
|
| 259 |
+
expect(encoder.encode({ type: "text_delta", contentIndex: 0, delta: "He", partial })).toBeUndefined();
|
| 260 |
+
const remainder = encoder.encode({ type: "text_delta", contentIndex: 0, delta: "llo", partial });
|
| 261 |
+
if (remainder === undefined) throw new Error("Expected uncovered text delta");
|
| 262 |
+
frames.push(remainder);
|
| 263 |
+
|
| 264 |
+
expect(remainder).toEqual({ type: "text_delta", contentIndex: 0, delta: "lo" });
|
| 265 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([{ type: "text", text: "Hello" }]);
|
| 266 |
+
});
|
| 267 |
+
|
| 268 |
+
it("checkpoints queued tool JSON without replaying covered deltas", () => {
|
| 269 |
+
const partial = seed();
|
| 270 |
+
const toolCall = { type: "toolCall" as const, id: "call", name: "write", arguments: {} };
|
| 271 |
+
const events: AssistantMessageEvent[] = [{ type: "start", partial }];
|
| 272 |
+
partial.content.push(toolCall);
|
| 273 |
+
events.push({ type: "toolcall_start", contentIndex: 0, partial });
|
| 274 |
+
toolCall.arguments = { path: "README.md" };
|
| 275 |
+
events.push(
|
| 276 |
+
{ type: "toolcall_delta", contentIndex: 0, delta: '{"path":"READ', partial },
|
| 277 |
+
{ type: "toolcall_delta", contentIndex: 0, delta: 'ME.md"}', partial },
|
| 278 |
+
);
|
| 279 |
+
|
| 280 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 281 |
+
const frames = events.flatMap((event) => {
|
| 282 |
+
const encoded = encoder.encode(event);
|
| 283 |
+
return encoded === undefined ? [] : [encoded];
|
| 284 |
+
});
|
| 285 |
+
expect(frames.map((item) => item.type)).toEqual(["start", "toolcall_start", "toolcall_checkpoint"]);
|
| 286 |
+
expect(frames.at(-1)).toEqual({
|
| 287 |
+
type: "toolcall_checkpoint",
|
| 288 |
+
contentIndex: 0,
|
| 289 |
+
json: '{"path":"README.md"}',
|
| 290 |
+
});
|
| 291 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
|
| 292 |
+
{ type: "toolCall", id: "call", name: "write", arguments: { path: "README.md" } },
|
| 293 |
+
]);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it("resumes legacy grammar tool JSON from initial arguments", () => {
|
| 297 |
+
const partial = seed();
|
| 298 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 299 |
+
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
|
| 300 |
+
const toolCall = { type: "toolCall" as const, id: "call", name: "bash", arguments: { input: "a" } };
|
| 301 |
+
partial.content.push(toolCall);
|
| 302 |
+
frames.push(frame(encoder, { type: "toolcall_start", contentIndex: 0, partial }));
|
| 303 |
+
toolCall.arguments = { input: "ab" };
|
| 304 |
+
frames.push(
|
| 305 |
+
frame(encoder, {
|
| 306 |
+
type: "toolcall_delta",
|
| 307 |
+
contentIndex: 0,
|
| 308 |
+
delta: '{"input":"ab',
|
| 309 |
+
partial,
|
| 310 |
+
}),
|
| 311 |
+
);
|
| 312 |
+
toolCall.arguments = { input: "abc" };
|
| 313 |
+
frames.push(
|
| 314 |
+
frame(encoder, {
|
| 315 |
+
type: "toolcall_delta",
|
| 316 |
+
contentIndex: 0,
|
| 317 |
+
delta: 'c"}',
|
| 318 |
+
partial,
|
| 319 |
+
}),
|
| 320 |
+
);
|
| 321 |
+
|
| 322 |
+
expect(frames.slice(2)).toEqual([
|
| 323 |
+
{ type: "toolcall_checkpoint", contentIndex: 0, json: '{"input":"ab' },
|
| 324 |
+
{ type: "toolcall_delta", contentIndex: 0, delta: 'c"}' },
|
| 325 |
+
]);
|
| 326 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
|
| 327 |
+
{ type: "toolCall", id: "call", name: "bash", arguments: { input: "abc" } },
|
| 328 |
+
]);
|
| 329 |
+
});
|
| 330 |
+
|
| 331 |
+
it("streams tool JSON compactly from an empty argument start", () => {
|
| 332 |
+
const partial = seed();
|
| 333 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 334 |
+
const frames: AssistantMessageFrame[] = [frame(encoder, { type: "start", partial })];
|
| 335 |
+
const toolCall = { type: "toolCall" as const, id: "call", name: "bash", arguments: {} };
|
| 336 |
+
partial.content.push(toolCall);
|
| 337 |
+
frames.push(frame(encoder, { type: "toolcall_start", contentIndex: 0, partial }));
|
| 338 |
+
toolCall.arguments = { command: "ls -la /tmp" };
|
| 339 |
+
frames.push(
|
| 340 |
+
frame(encoder, {
|
| 341 |
+
type: "toolcall_delta",
|
| 342 |
+
contentIndex: 0,
|
| 343 |
+
delta: '{"command":"ls -la /tmp"}',
|
| 344 |
+
partial,
|
| 345 |
+
}),
|
| 346 |
+
);
|
| 347 |
+
|
| 348 |
+
expect(frames.at(-1)).toEqual({
|
| 349 |
+
type: "toolcall_delta",
|
| 350 |
+
contentIndex: 0,
|
| 351 |
+
delta: '{"command":"ls -la /tmp"}',
|
| 352 |
+
});
|
| 353 |
+
expect(reduceAssistantMessageFrames(frames)?.content[0]).toMatchObject({
|
| 354 |
+
type: "toolCall",
|
| 355 |
+
arguments: { command: "ls -la /tmp" },
|
| 356 |
+
});
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
it("accepts a pre-generation error but rejects success or updates before start", () => {
|
| 360 |
+
const failed = seed();
|
| 361 |
+
failed.stopReason = "error";
|
| 362 |
+
failed.errorMessage = "setup failed";
|
| 363 |
+
expect(
|
| 364 |
+
new AssistantMessageFrameEncoder().encode({ type: "error", reason: "error", error: failed }),
|
| 365 |
+
).toBeUndefined();
|
| 366 |
+
|
| 367 |
+
const completed = seed();
|
| 368 |
+
completed.stopReason = "stop";
|
| 369 |
+
expect(() =>
|
| 370 |
+
new AssistantMessageFrameEncoder().encode({ type: "done", reason: "stop", message: completed }),
|
| 371 |
+
).toThrow("done event appears before start");
|
| 372 |
+
expect(() =>
|
| 373 |
+
new AssistantMessageFrameEncoder().encode({
|
| 374 |
+
type: "text_delta",
|
| 375 |
+
contentIndex: 0,
|
| 376 |
+
delta: "x",
|
| 377 |
+
partial: seed(),
|
| 378 |
+
}),
|
| 379 |
+
).toThrow("text_delta event appears before start");
|
| 380 |
+
});
|
| 381 |
+
|
| 382 |
+
it("treats end signature metadata, including absence, as authoritative", () => {
|
| 383 |
+
const frames: AssistantMessageFrame[] = [
|
| 384 |
+
{ type: "start", partial: seed() },
|
| 385 |
+
{
|
| 386 |
+
type: "text_start",
|
| 387 |
+
contentIndex: 0,
|
| 388 |
+
content: { type: "text", text: "", textSignature: "stale-text" },
|
| 389 |
+
},
|
| 390 |
+
{ type: "text_end", contentIndex: 0, content: "" },
|
| 391 |
+
{
|
| 392 |
+
type: "thinking_start",
|
| 393 |
+
contentIndex: 1,
|
| 394 |
+
content: {
|
| 395 |
+
type: "thinking",
|
| 396 |
+
thinking: "",
|
| 397 |
+
thinkingSignature: "stale-thinking",
|
| 398 |
+
redacted: true,
|
| 399 |
+
},
|
| 400 |
+
},
|
| 401 |
+
{ type: "thinking_end", contentIndex: 1, content: "", thinkingSignature: "", redacted: false },
|
| 402 |
+
{
|
| 403 |
+
type: "toolcall_start",
|
| 404 |
+
contentIndex: 2,
|
| 405 |
+
toolCall: {
|
| 406 |
+
type: "toolCall",
|
| 407 |
+
id: "call",
|
| 408 |
+
name: "read",
|
| 409 |
+
arguments: {},
|
| 410 |
+
thoughtSignature: "stale-tool",
|
| 411 |
+
namespace: "stale-namespace",
|
| 412 |
+
},
|
| 413 |
+
},
|
| 414 |
+
{ type: "toolcall_end", contentIndex: 2, id: "call", name: "read", arguments: {} },
|
| 415 |
+
];
|
| 416 |
+
|
| 417 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
|
| 418 |
+
{ type: "text", text: "" },
|
| 419 |
+
{ type: "thinking", thinking: "", thinkingSignature: "", redacted: false },
|
| 420 |
+
{ type: "toolCall", id: "call", name: "read", arguments: {} },
|
| 421 |
+
]);
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
it("stores authoritative final arguments in toolcall_end frames", () => {
|
| 425 |
+
const partial = seed();
|
| 426 |
+
const toolCall = {
|
| 427 |
+
type: "toolCall" as const,
|
| 428 |
+
id: "call-1",
|
| 429 |
+
name: "read",
|
| 430 |
+
arguments: { path: "README.md" },
|
| 431 |
+
thoughtSignature: "thought",
|
| 432 |
+
namespace: "files",
|
| 433 |
+
};
|
| 434 |
+
partial.content.push(toolCall);
|
| 435 |
+
|
| 436 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 437 |
+
frame(encoder, { type: "start", partial });
|
| 438 |
+
frame(encoder, { type: "toolcall_start", contentIndex: 0, partial });
|
| 439 |
+
const end = frame(encoder, { type: "toolcall_end", contentIndex: 0, toolCall, partial });
|
| 440 |
+
expect(end).toEqual({
|
| 441 |
+
type: "toolcall_end",
|
| 442 |
+
contentIndex: 0,
|
| 443 |
+
id: "call-1",
|
| 444 |
+
name: "read",
|
| 445 |
+
arguments: { path: "README.md" },
|
| 446 |
+
thoughtSignature: "thought",
|
| 447 |
+
namespace: "files",
|
| 448 |
+
});
|
| 449 |
+
});
|
| 450 |
+
|
| 451 |
+
it("whitelists public block fields from provider-shaped partials", () => {
|
| 452 |
+
const partial = seed();
|
| 453 |
+
const text = { type: "text" as const, text: "visible", textSignature: "text-sig", index: 4 };
|
| 454 |
+
const thinking = {
|
| 455 |
+
type: "thinking" as const,
|
| 456 |
+
thinking: "reasoning",
|
| 457 |
+
thinkingSignature: "thinking-sig",
|
| 458 |
+
redacted: false,
|
| 459 |
+
index: 5,
|
| 460 |
+
};
|
| 461 |
+
const toolCall = {
|
| 462 |
+
type: "toolCall" as const,
|
| 463 |
+
id: "call",
|
| 464 |
+
name: "run",
|
| 465 |
+
arguments: { value: 1 },
|
| 466 |
+
thoughtSignature: "tool-sig",
|
| 467 |
+
namespace: "tools",
|
| 468 |
+
partialJson: '{"value":',
|
| 469 |
+
streamIndex: 6,
|
| 470 |
+
};
|
| 471 |
+
partial.content.push(text, thinking, toolCall);
|
| 472 |
+
const partialWithScratch = partial as AssistantMessage & { outputIndex?: number };
|
| 473 |
+
partialWithScratch.outputIndex = 3;
|
| 474 |
+
|
| 475 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 476 |
+
const start = frame(encoder, { type: "start", partial });
|
| 477 |
+
const textStart = frame(encoder, { type: "text_start", contentIndex: 0, partial });
|
| 478 |
+
const thinkingStart = frame(encoder, { type: "thinking_start", contentIndex: 1, partial });
|
| 479 |
+
const toolStart = frame(encoder, { type: "toolcall_start", contentIndex: 2, partial });
|
| 480 |
+
|
| 481 |
+
expect(start.type === "start" && start.partial.content).toEqual([]);
|
| 482 |
+
expect(start).not.toHaveProperty("partial.outputIndex");
|
| 483 |
+
expect(textStart).not.toHaveProperty("content.index");
|
| 484 |
+
expect(thinkingStart).not.toHaveProperty("content.index");
|
| 485 |
+
expect(toolStart).not.toHaveProperty("toolCall.partialJson");
|
| 486 |
+
expect(toolStart).not.toHaveProperty("toolCall.streamIndex");
|
| 487 |
+
});
|
| 488 |
+
|
| 489 |
+
it("supports interleaved streams by contentIndex", () => {
|
| 490 |
+
const frames: AssistantMessageFrame[] = [
|
| 491 |
+
{ type: "start", partial: seed() },
|
| 492 |
+
{ type: "text_start", contentIndex: 0, content: { type: "text", text: "" } },
|
| 493 |
+
{
|
| 494 |
+
type: "toolcall_start",
|
| 495 |
+
contentIndex: 1,
|
| 496 |
+
toolCall: { type: "toolCall", id: "call", name: "lookup", arguments: {} },
|
| 497 |
+
},
|
| 498 |
+
{ type: "thinking_start", contentIndex: 2, content: { type: "thinking", thinking: "" } },
|
| 499 |
+
{ type: "text_delta", contentIndex: 0, delta: "answer" },
|
| 500 |
+
{ type: "toolcall_delta", contentIndex: 1, delta: '{"query":"pi"}' },
|
| 501 |
+
{ type: "thinking_delta", contentIndex: 2, delta: "check" },
|
| 502 |
+
{ type: "toolcall_end", contentIndex: 1, id: "call", name: "lookup", arguments: { query: "pi" } },
|
| 503 |
+
{ type: "text_end", contentIndex: 0, content: "answer" },
|
| 504 |
+
{ type: "thinking_end", contentIndex: 2, content: "check" },
|
| 505 |
+
];
|
| 506 |
+
|
| 507 |
+
expect(reduceAssistantMessageFrames(frames)?.content).toEqual([
|
| 508 |
+
{ type: "text", text: "answer" },
|
| 509 |
+
{ type: "toolCall", id: "call", name: "lookup", arguments: { query: "pi" } },
|
| 510 |
+
{ type: "thinking", thinking: "check" },
|
| 511 |
+
]);
|
| 512 |
+
});
|
| 513 |
+
|
| 514 |
+
it("snapshots mutable event data and keeps reduction pure", () => {
|
| 515 |
+
const partial = seed();
|
| 516 |
+
partial.diagnostics = [{ type: "test", timestamp: 2, details: { value: "original" } }];
|
| 517 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 518 |
+
const start = frame(encoder, { type: "start", partial });
|
| 519 |
+
partial.diagnostics[0]!.details!.value = "mutated";
|
| 520 |
+
partial.usage.cost.total = 99;
|
| 521 |
+
|
| 522 |
+
partial.content.push({
|
| 523 |
+
type: "toolCall",
|
| 524 |
+
id: "call",
|
| 525 |
+
name: "run",
|
| 526 |
+
arguments: { nested: { value: "original" } },
|
| 527 |
+
});
|
| 528 |
+
const toolStart = frame(encoder, { type: "toolcall_start", contentIndex: 0, partial });
|
| 529 |
+
const sourceTool = partial.content[0];
|
| 530 |
+
if (sourceTool?.type !== "toolCall") throw new Error("Expected source tool call");
|
| 531 |
+
(sourceTool.arguments.nested as Record<string, unknown>).value = "mutated";
|
| 532 |
+
|
| 533 |
+
const reduced = reduceAssistantMessageFrames([start, toolStart]);
|
| 534 |
+
expect(reduced?.diagnostics?.[0]?.details?.value).toBe("original");
|
| 535 |
+
expect(reduced?.usage.cost.total).toBe(0);
|
| 536 |
+
expect(reduced?.content[0]).toMatchObject({ arguments: { nested: { value: "original" } } });
|
| 537 |
+
|
| 538 |
+
if (reduced?.content[0]?.type !== "toolCall") throw new Error("Expected reduced tool call");
|
| 539 |
+
reduced.content[0].arguments.nested = "changed-output";
|
| 540 |
+
expect(toolStart.type === "toolcall_start" && toolStart.toolCall.arguments.nested).toEqual({
|
| 541 |
+
value: "original",
|
| 542 |
+
});
|
| 543 |
+
});
|
| 544 |
+
|
| 545 |
+
it("omits terminal events because settlement is separate", () => {
|
| 546 |
+
const message = seed();
|
| 547 |
+
const completed = new AssistantMessageFrameEncoder();
|
| 548 |
+
completed.encode({ type: "start", partial: message });
|
| 549 |
+
message.stopReason = "stop";
|
| 550 |
+
expect(completed.encode({ type: "done", reason: "stop", message })).toBeUndefined();
|
| 551 |
+
message.stopReason = "error";
|
| 552 |
+
message.errorMessage = "failed";
|
| 553 |
+
expect(
|
| 554 |
+
new AssistantMessageFrameEncoder().encode({ type: "error", reason: "error", error: message }),
|
| 555 |
+
).toBeUndefined();
|
| 556 |
+
});
|
| 557 |
+
|
| 558 |
+
it("returns undefined when there is no start frame", () => {
|
| 559 |
+
expect(reduceAssistantMessageFrames([])).toBeUndefined();
|
| 560 |
+
expect(reduceAssistantMessageFrames([{ type: "text_delta", contentIndex: 0, delta: "x" }])).toBeUndefined();
|
| 561 |
+
});
|
| 562 |
+
|
| 563 |
+
it("rejects frames before start, wrong block kinds, duplicate ends, and index gaps", () => {
|
| 564 |
+
expect(() =>
|
| 565 |
+
reduceAssistantMessageFrames([
|
| 566 |
+
{ type: "text_delta", contentIndex: 0, delta: "x" },
|
| 567 |
+
{ type: "start", partial: seed() },
|
| 568 |
+
]),
|
| 569 |
+
).toThrow("before the start frame");
|
| 570 |
+
expect(() =>
|
| 571 |
+
reduceAssistantMessageFrames([
|
| 572 |
+
{ type: "start", partial: seed() },
|
| 573 |
+
{
|
| 574 |
+
type: "toolcall_start",
|
| 575 |
+
contentIndex: 0,
|
| 576 |
+
toolCall: { type: "toolCall", id: "call", name: "run", arguments: {} },
|
| 577 |
+
},
|
| 578 |
+
{ type: "text_delta", contentIndex: 0, delta: "wrong" },
|
| 579 |
+
]),
|
| 580 |
+
).toThrow("expected text block");
|
| 581 |
+
expect(() =>
|
| 582 |
+
reduceAssistantMessageFrames([
|
| 583 |
+
{ type: "start", partial: seed() },
|
| 584 |
+
{ type: "text_start", contentIndex: 0, content: { type: "text", text: "" } },
|
| 585 |
+
{ type: "text_end", contentIndex: 0, content: "" },
|
| 586 |
+
{ type: "text_end", contentIndex: 0, content: "" },
|
| 587 |
+
]),
|
| 588 |
+
).toThrow("follows the end");
|
| 589 |
+
expect(() =>
|
| 590 |
+
reduceAssistantMessageFrames([
|
| 591 |
+
{ type: "start", partial: seed() },
|
| 592 |
+
{ type: "text_start", contentIndex: 1, content: { type: "text", text: "" } },
|
| 593 |
+
]),
|
| 594 |
+
).toThrow("would leave a gap");
|
| 595 |
+
});
|
| 596 |
+
|
| 597 |
+
it("rejects conversion events whose contentIndex points to the wrong block kind", () => {
|
| 598 |
+
const partial = seed();
|
| 599 |
+
const encoder = new AssistantMessageFrameEncoder();
|
| 600 |
+
encoder.encode({ type: "start", partial });
|
| 601 |
+
partial.content.push({ type: "thinking", thinking: "" });
|
| 602 |
+
expect(() => encoder.encode({ type: "text_start", contentIndex: 0, partial })).toThrow(
|
| 603 |
+
"text_start event points to thinking block",
|
| 604 |
+
);
|
| 605 |
+
});
|
| 606 |
+
});
|
packages/ai/test/azure-openai-base-url.test.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { arch, platform, release } from "node:os";
|
| 2 |
+
import { Type } from "typebox";
|
| 3 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { stream as streamAzureOpenAIResponses } from "../src/api/azure-openai-responses.ts";
|
| 5 |
+
import { getModel } from "../src/compat.ts";
|
| 6 |
+
import type { Context, Model } from "../src/types.ts";
|
| 7 |
+
|
| 8 |
+
interface CapturedAzureClientOptions {
|
| 9 |
+
apiKey: string;
|
| 10 |
+
apiVersion: string;
|
| 11 |
+
dangerouslyAllowBrowser: boolean;
|
| 12 |
+
defaultHeaders?: Record<string, string>;
|
| 13 |
+
baseURL: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
interface CapturedAzureResponsesPayload {
|
| 17 |
+
prompt_cache_key?: string;
|
| 18 |
+
store?: boolean;
|
| 19 |
+
tools?: Array<{ strict?: boolean }>;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
const azureMock = vi.hoisted(() => ({
|
| 23 |
+
constructorCalls: [] as CapturedAzureClientOptions[],
|
| 24 |
+
lastParams: undefined as CapturedAzureResponsesPayload | undefined,
|
| 25 |
+
}));
|
| 26 |
+
|
| 27 |
+
vi.mock("openai", () => {
|
| 28 |
+
class AzureOpenAI {
|
| 29 |
+
responses = {
|
| 30 |
+
create: (params: CapturedAzureResponsesPayload) => {
|
| 31 |
+
azureMock.lastParams = params;
|
| 32 |
+
throw new Error("mock create");
|
| 33 |
+
},
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
constructor(config: CapturedAzureClientOptions) {
|
| 37 |
+
azureMock.constructorCalls.push(config);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
return { AzureOpenAI };
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
const PI_USER_AGENT = `pi (${platform()} ${release()}; ${arch()})`;
|
| 45 |
+
|
| 46 |
+
const context: Context = {
|
| 47 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
const originalAzureOpenAIBaseUrl = process.env.AZURE_OPENAI_BASE_URL;
|
| 51 |
+
const originalAzureOpenAIResourceName = process.env.AZURE_OPENAI_RESOURCE_NAME;
|
| 52 |
+
const originalAzureOpenAIApiVersion = process.env.AZURE_OPENAI_API_VERSION;
|
| 53 |
+
const originalAzureOpenAIApiKey = process.env.AZURE_OPENAI_API_KEY;
|
| 54 |
+
|
| 55 |
+
beforeEach(() => {
|
| 56 |
+
azureMock.constructorCalls.length = 0;
|
| 57 |
+
azureMock.lastParams = undefined;
|
| 58 |
+
delete process.env.AZURE_OPENAI_BASE_URL;
|
| 59 |
+
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
|
| 60 |
+
delete process.env.AZURE_OPENAI_API_VERSION;
|
| 61 |
+
delete process.env.AZURE_OPENAI_API_KEY;
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
afterEach(() => {
|
| 65 |
+
if (originalAzureOpenAIBaseUrl === undefined) {
|
| 66 |
+
delete process.env.AZURE_OPENAI_BASE_URL;
|
| 67 |
+
} else {
|
| 68 |
+
process.env.AZURE_OPENAI_BASE_URL = originalAzureOpenAIBaseUrl;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
if (originalAzureOpenAIResourceName === undefined) {
|
| 72 |
+
delete process.env.AZURE_OPENAI_RESOURCE_NAME;
|
| 73 |
+
} else {
|
| 74 |
+
process.env.AZURE_OPENAI_RESOURCE_NAME = originalAzureOpenAIResourceName;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if (originalAzureOpenAIApiVersion === undefined) {
|
| 78 |
+
delete process.env.AZURE_OPENAI_API_VERSION;
|
| 79 |
+
} else {
|
| 80 |
+
process.env.AZURE_OPENAI_API_VERSION = originalAzureOpenAIApiVersion;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
if (originalAzureOpenAIApiKey === undefined) {
|
| 84 |
+
delete process.env.AZURE_OPENAI_API_KEY;
|
| 85 |
+
} else {
|
| 86 |
+
process.env.AZURE_OPENAI_API_KEY = originalAzureOpenAIApiKey;
|
| 87 |
+
}
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
async function captureClientBaseUrl(baseUrl: string): Promise<string> {
|
| 91 |
+
process.env.AZURE_OPENAI_BASE_URL = baseUrl;
|
| 92 |
+
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 93 |
+
await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
|
| 94 |
+
expect(azureMock.constructorCalls).toHaveLength(1);
|
| 95 |
+
return azureMock.constructorCalls[0].baseURL;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
async function captureClientHeaders(headers?: Record<string, string>): Promise<Record<string, string>> {
|
| 99 |
+
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 100 |
+
await streamAzureOpenAIResponses(model, context, {
|
| 101 |
+
apiKey: "test-api-key",
|
| 102 |
+
azureBaseUrl: "https://my-resource.openai.azure.com",
|
| 103 |
+
headers,
|
| 104 |
+
}).result();
|
| 105 |
+
expect(azureMock.constructorCalls).toHaveLength(1);
|
| 106 |
+
return azureMock.constructorCalls[0].defaultHeaders ?? {};
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
describe("azure-openai-responses base URL normalization", () => {
|
| 110 |
+
it("normalizes Cognitive Services root endpoints to /openai/v1", async () => {
|
| 111 |
+
const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.cognitiveservices.azure.com");
|
| 112 |
+
expect(baseURL).toBe("https://marc-quicktests-resource.cognitiveservices.azure.com/openai/v1");
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
it("normalizes Microsoft Foundry root endpoints to /openai/v1", async () => {
|
| 116 |
+
const baseURL = await captureClientBaseUrl("https://marc-quicktests-resource.ai.azure.com");
|
| 117 |
+
expect(baseURL).toBe("https://marc-quicktests-resource.ai.azure.com/openai/v1");
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it("normalizes Azure OpenAI root endpoints to /openai/v1", async () => {
|
| 121 |
+
const baseURL = await captureClientBaseUrl("https://my-resource.openai.azure.com");
|
| 122 |
+
expect(baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
it("normalizes /openai to /openai/v1", async () => {
|
| 126 |
+
const baseURL = await captureClientBaseUrl("https://my-resource.cognitiveservices.azure.com/openai");
|
| 127 |
+
expect(baseURL).toBe("https://my-resource.cognitiveservices.azure.com/openai/v1");
|
| 128 |
+
});
|
| 129 |
+
|
| 130 |
+
it("preserves /openai/v1 endpoints", async () => {
|
| 131 |
+
const baseURL = await captureClientBaseUrl("https://my-resource.cognitiveservices.azure.com/openai/v1");
|
| 132 |
+
expect(baseURL).toBe("https://my-resource.cognitiveservices.azure.com/openai/v1");
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
it("normalizes /openai/v1/responses to /openai/v1", async () => {
|
| 136 |
+
const baseURL = await captureClientBaseUrl("https://my-resource.services.ai.azure.com/openai/v1/responses");
|
| 137 |
+
expect(baseURL).toBe("https://my-resource.services.ai.azure.com/openai/v1");
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it("preserves explicit non-Azure proxy paths", async () => {
|
| 141 |
+
const baseURL = await captureClientBaseUrl("https://my-proxy.example.com/v1");
|
| 142 |
+
expect(baseURL).toBe("https://my-proxy.example.com/v1");
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("strips query params when normalizing Azure host URLs", async () => {
|
| 146 |
+
const baseURL = await captureClientBaseUrl("https://my-resource.openai.azure.com/openai?api-version=2024-12-01");
|
| 147 |
+
expect(baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
it("preserves query params on non-Azure proxy URLs", async () => {
|
| 151 |
+
const baseURL = await captureClientBaseUrl("https://my-proxy.example.com/v1?custom=true");
|
| 152 |
+
expect(baseURL).toBe("https://my-proxy.example.com/v1?custom=true");
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it("throws on invalid URLs", async () => {
|
| 156 |
+
process.env.AZURE_OPENAI_BASE_URL = "not-a-url";
|
| 157 |
+
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 158 |
+
const result = await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
|
| 159 |
+
expect(result.stopReason).toBe("error");
|
| 160 |
+
expect(result.errorMessage).toContain("Invalid Azure OpenAI base URL");
|
| 161 |
+
});
|
| 162 |
+
|
| 163 |
+
it("clamps prompt_cache_key to OpenAI's 64-character limit", async () => {
|
| 164 |
+
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 165 |
+
await streamAzureOpenAIResponses(model, context, {
|
| 166 |
+
apiKey: "test-api-key",
|
| 167 |
+
azureBaseUrl: "https://my-resource.openai.azure.com",
|
| 168 |
+
sessionId: "x".repeat(67),
|
| 169 |
+
}).result();
|
| 170 |
+
|
| 171 |
+
expect(azureMock.lastParams?.prompt_cache_key).toBe("x".repeat(64));
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it("disables server-side response storage", async () => {
|
| 175 |
+
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 176 |
+
await streamAzureOpenAIResponses(model, context, {
|
| 177 |
+
apiKey: "test-api-key",
|
| 178 |
+
azureBaseUrl: "https://my-resource.openai.azure.com",
|
| 179 |
+
}).result();
|
| 180 |
+
|
| 181 |
+
expect(azureMock.lastParams?.store).toBe(false);
|
| 182 |
+
});
|
| 183 |
+
|
| 184 |
+
it("honors supportsStrictMode: false", async () => {
|
| 185 |
+
const baseModel = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 186 |
+
const model: Model<"azure-openai-responses"> = {
|
| 187 |
+
...baseModel,
|
| 188 |
+
compat: { ...baseModel.compat, supportsStrictMode: false },
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
await streamAzureOpenAIResponses(
|
| 192 |
+
model,
|
| 193 |
+
{
|
| 194 |
+
...context,
|
| 195 |
+
tools: [
|
| 196 |
+
{
|
| 197 |
+
name: "preferred",
|
| 198 |
+
description: "Preferred constrained tool",
|
| 199 |
+
parameters: Type.Object({ value: Type.String() }),
|
| 200 |
+
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
| 201 |
+
},
|
| 202 |
+
],
|
| 203 |
+
},
|
| 204 |
+
{ apiKey: "test-api-key", azureBaseUrl: "https://my-resource.openai.azure.com" },
|
| 205 |
+
).result();
|
| 206 |
+
|
| 207 |
+
expect(azureMock.lastParams?.tools?.[0]).not.toHaveProperty("strict");
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
it("builds correct default URL from AZURE_OPENAI_RESOURCE_NAME", async () => {
|
| 211 |
+
process.env.AZURE_OPENAI_RESOURCE_NAME = "my-resource";
|
| 212 |
+
const model = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 213 |
+
await streamAzureOpenAIResponses(model, context, { apiKey: "test-api-key" }).result();
|
| 214 |
+
expect(azureMock.constructorCalls).toHaveLength(1);
|
| 215 |
+
expect(azureMock.constructorCalls[0].baseURL).toBe("https://my-resource.openai.azure.com/openai/v1");
|
| 216 |
+
});
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
describe("azure-openai-responses user agent", () => {
|
| 220 |
+
it("uses pi's User-Agent by default", async () => {
|
| 221 |
+
expect((await captureClientHeaders())["User-Agent"]).toBe(PI_USER_AGENT);
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
it("lets explicit headers override the default User-Agent", async () => {
|
| 225 |
+
expect((await captureClientHeaders({ "User-Agent": "custom-agent" }))["User-Agent"]).toBe("custom-agent");
|
| 226 |
+
});
|
| 227 |
+
});
|
packages/ai/test/azure-openai-tool-choice.test.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Type } from "typebox";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { stream, streamSimple } from "../src/api/azure-openai-responses.ts";
|
| 4 |
+
import type { Model } from "../src/types.ts";
|
| 5 |
+
|
| 6 |
+
const model: Model<"azure-openai-responses"> = {
|
| 7 |
+
id: "test-deployment",
|
| 8 |
+
name: "Test Deployment",
|
| 9 |
+
api: "azure-openai-responses",
|
| 10 |
+
provider: "azure-openai-responses",
|
| 11 |
+
baseUrl: "http://127.0.0.1:9/openai/v1",
|
| 12 |
+
reasoning: false,
|
| 13 |
+
input: ["text"],
|
| 14 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 15 |
+
contextWindow: 10_000,
|
| 16 |
+
maxTokens: 1_000,
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
describe("Azure OpenAI tool choice", () => {
|
| 20 |
+
it("forwards provider-specific tool choice while preserving tool definitions", async () => {
|
| 21 |
+
let payload: unknown;
|
| 22 |
+
const result = stream(
|
| 23 |
+
model,
|
| 24 |
+
{
|
| 25 |
+
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
|
| 26 |
+
tools: [
|
| 27 |
+
{
|
| 28 |
+
name: "read",
|
| 29 |
+
description: "Read a file",
|
| 30 |
+
parameters: Type.Object({ path: Type.String() }),
|
| 31 |
+
},
|
| 32 |
+
],
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
apiKey: "test-key",
|
| 36 |
+
toolChoice: "required",
|
| 37 |
+
onPayload: (requestPayload) => {
|
| 38 |
+
payload = requestPayload;
|
| 39 |
+
throw new Error("payload captured");
|
| 40 |
+
},
|
| 41 |
+
},
|
| 42 |
+
);
|
| 43 |
+
|
| 44 |
+
await result.result();
|
| 45 |
+
|
| 46 |
+
expect(payload).toMatchObject({ tool_choice: "required" });
|
| 47 |
+
expect((payload as { tools?: unknown[] }).tools).toHaveLength(1);
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
it("forwards provider-neutral tool choice from simple options", async () => {
|
| 51 |
+
let payload: unknown;
|
| 52 |
+
const result = streamSimple(
|
| 53 |
+
model,
|
| 54 |
+
{
|
| 55 |
+
messages: [{ role: "user", content: "Summarize this", timestamp: 1 }],
|
| 56 |
+
tools: [
|
| 57 |
+
{
|
| 58 |
+
name: "read",
|
| 59 |
+
description: "Read a file",
|
| 60 |
+
parameters: Type.Object({ path: Type.String() }),
|
| 61 |
+
},
|
| 62 |
+
],
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
apiKey: "test-key",
|
| 66 |
+
toolChoice: "none",
|
| 67 |
+
onPayload: (requestPayload) => {
|
| 68 |
+
payload = requestPayload;
|
| 69 |
+
throw new Error("payload captured");
|
| 70 |
+
},
|
| 71 |
+
},
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
await result.result();
|
| 75 |
+
|
| 76 |
+
expect(payload).toMatchObject({ tool_choice: "none" });
|
| 77 |
+
expect((payload as { tools?: unknown[] }).tools).toHaveLength(1);
|
| 78 |
+
});
|
| 79 |
+
});
|
packages/ai/test/azure-utils.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Utility functions for Azure OpenAI tests
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
|
| 6 |
+
const map = new Map<string, string>();
|
| 7 |
+
if (!value) return map;
|
| 8 |
+
for (const entry of value.split(",")) {
|
| 9 |
+
const trimmed = entry.trim();
|
| 10 |
+
if (!trimmed) continue;
|
| 11 |
+
const [modelId, deploymentName] = trimmed.split("=", 2);
|
| 12 |
+
if (!modelId || !deploymentName) continue;
|
| 13 |
+
map.set(modelId.trim(), deploymentName.trim());
|
| 14 |
+
}
|
| 15 |
+
return map;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function hasAzureOpenAICredentials(): boolean {
|
| 19 |
+
const hasKey = !!process.env.AZURE_OPENAI_API_KEY;
|
| 20 |
+
const hasBaseUrl = !!(process.env.AZURE_OPENAI_BASE_URL || process.env.AZURE_OPENAI_RESOURCE_NAME);
|
| 21 |
+
return hasKey && hasBaseUrl;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function resolveAzureDeploymentName(modelId: string): string | undefined {
|
| 25 |
+
const mapValue = process.env.AZURE_OPENAI_DEPLOYMENT_NAME_MAP;
|
| 26 |
+
if (!mapValue) return undefined;
|
| 27 |
+
return parseDeploymentNameMap(mapValue).get(modelId);
|
| 28 |
+
}
|
packages/ai/test/baseten-models.test.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 2 |
+
import { getModel, streamSimple } from "../src/compat.ts";
|
| 3 |
+
import { findEnvKeys, getEnvApiKey } from "../src/env-api-keys.ts";
|
| 4 |
+
import { getSupportedThinkingLevels } from "../src/models.ts";
|
| 5 |
+
|
| 6 |
+
const originalBasetenApiKey = process.env.BASETEN_API_KEY;
|
| 7 |
+
|
| 8 |
+
afterEach(() => {
|
| 9 |
+
if (originalBasetenApiKey === undefined) {
|
| 10 |
+
delete process.env.BASETEN_API_KEY;
|
| 11 |
+
} else {
|
| 12 |
+
process.env.BASETEN_API_KEY = originalBasetenApiKey;
|
| 13 |
+
}
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
describe("Baseten models", () => {
|
| 17 |
+
it("keeps both GLM 5.2 endpoints text-only", () => {
|
| 18 |
+
expect(getModel("baseten", "zai-org/GLM-5.2").input).toEqual(["text"]);
|
| 19 |
+
expect(getModel("baseten", "zai-org/GLM-5.2-Fast").input).toEqual(["text"]);
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it("models Kimi K2.6 reasoning as an explicit off/on toggle", async () => {
|
| 23 |
+
const model = getModel("baseten", "moonshotai/Kimi-K2.6");
|
| 24 |
+
let payload: Record<string, unknown> | undefined;
|
| 25 |
+
|
| 26 |
+
expect(model.thinkingLevelMap).toEqual({
|
| 27 |
+
off: "off",
|
| 28 |
+
minimal: null,
|
| 29 |
+
low: null,
|
| 30 |
+
medium: null,
|
| 31 |
+
high: "high",
|
| 32 |
+
xhigh: null,
|
| 33 |
+
max: null,
|
| 34 |
+
});
|
| 35 |
+
expect(model.compat).toMatchObject({
|
| 36 |
+
supportsReasoningEffort: false,
|
| 37 |
+
thinkingFormat: "baseten",
|
| 38 |
+
chatTemplateArgs: { enable_thinking: { $var: "thinking.enabled" } },
|
| 39 |
+
});
|
| 40 |
+
expect(getSupportedThinkingLevels(model)).toEqual(["off", "high"]);
|
| 41 |
+
|
| 42 |
+
await streamSimple(
|
| 43 |
+
model,
|
| 44 |
+
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
|
| 45 |
+
{
|
| 46 |
+
apiKey: "test-baseten-key",
|
| 47 |
+
reasoning: "high",
|
| 48 |
+
onPayload: (value) => {
|
| 49 |
+
payload = value as Record<string, unknown>;
|
| 50 |
+
throw new Error("payload captured");
|
| 51 |
+
},
|
| 52 |
+
},
|
| 53 |
+
).result();
|
| 54 |
+
|
| 55 |
+
expect(payload?.chat_template_args).toEqual({ enable_thinking: true });
|
| 56 |
+
expect(payload?.reasoning_effort).toBeUndefined();
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it("sends Baseten chat_template_args with reasoning effort", async () => {
|
| 60 |
+
const model = getModel("baseten", "zai-org/GLM-5.2");
|
| 61 |
+
let payload: Record<string, unknown> | undefined;
|
| 62 |
+
|
| 63 |
+
await streamSimple(
|
| 64 |
+
model,
|
| 65 |
+
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
|
| 66 |
+
{
|
| 67 |
+
apiKey: "test-baseten-key",
|
| 68 |
+
reasoning: "high",
|
| 69 |
+
onPayload: (value) => {
|
| 70 |
+
payload = value as Record<string, unknown>;
|
| 71 |
+
throw new Error("payload captured");
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
).result();
|
| 75 |
+
|
| 76 |
+
expect(payload?.chat_template_args).toEqual({ enable_thinking: true });
|
| 77 |
+
expect(payload?.reasoning_effort).toBe("high");
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it("disables Baseten opt-in reasoning when thinking is off", async () => {
|
| 81 |
+
const model = getModel("baseten", "zai-org/GLM-5.2");
|
| 82 |
+
let payload: Record<string, unknown> | undefined;
|
| 83 |
+
|
| 84 |
+
await streamSimple(
|
| 85 |
+
model,
|
| 86 |
+
{ messages: [{ role: "user", content: "test", timestamp: 0 }] },
|
| 87 |
+
{
|
| 88 |
+
apiKey: "test-baseten-key",
|
| 89 |
+
onPayload: (value) => {
|
| 90 |
+
payload = value as Record<string, unknown>;
|
| 91 |
+
throw new Error("payload captured");
|
| 92 |
+
},
|
| 93 |
+
},
|
| 94 |
+
).result();
|
| 95 |
+
|
| 96 |
+
expect(payload?.chat_template_args).toEqual({ enable_thinking: false });
|
| 97 |
+
expect(payload?.reasoning_effort).toBe("none");
|
| 98 |
+
});
|
| 99 |
+
|
| 100 |
+
it("resolves BASETEN_API_KEY from the environment", () => {
|
| 101 |
+
process.env.BASETEN_API_KEY = "test-baseten-key";
|
| 102 |
+
|
| 103 |
+
expect(findEnvKeys("baseten")).toEqual(["BASETEN_API_KEY"]);
|
| 104 |
+
expect(getEnvApiKey("baseten")).toBe("test-baseten-key");
|
| 105 |
+
});
|
| 106 |
+
});
|
packages/ai/test/bedrock-credentials.test.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
const bedrockMock = vi.hoisted(() => ({
|
| 4 |
+
constructorCalls: [] as Array<Record<string, unknown>>,
|
| 5 |
+
}));
|
| 6 |
+
|
| 7 |
+
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
| 8 |
+
class BedrockRuntimeServiceException extends Error {}
|
| 9 |
+
|
| 10 |
+
class BedrockRuntimeClient {
|
| 11 |
+
constructor(config: Record<string, unknown>) {
|
| 12 |
+
bedrockMock.constructorCalls.push(config);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
send(): Promise<never> {
|
| 16 |
+
return Promise.reject(new Error("mock send"));
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
class ConverseStreamCommand {
|
| 21 |
+
readonly input: unknown;
|
| 22 |
+
|
| 23 |
+
constructor(input: unknown) {
|
| 24 |
+
this.input = input;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return {
|
| 29 |
+
BedrockRuntimeClient,
|
| 30 |
+
BedrockRuntimeServiceException,
|
| 31 |
+
ConverseStreamCommand,
|
| 32 |
+
StopReason: {
|
| 33 |
+
END_TURN: "end_turn",
|
| 34 |
+
STOP_SEQUENCE: "stop_sequence",
|
| 35 |
+
MAX_TOKENS: "max_tokens",
|
| 36 |
+
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
| 37 |
+
TOOL_USE: "tool_use",
|
| 38 |
+
},
|
| 39 |
+
CachePointType: { DEFAULT: "default" },
|
| 40 |
+
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
| 41 |
+
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
| 42 |
+
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
| 43 |
+
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
| 44 |
+
};
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
|
| 48 |
+
import { stream as streamBedrock } from "../src/compat.ts";
|
| 49 |
+
import { getBuiltinModel } from "../src/providers/all.ts";
|
| 50 |
+
import type { Context, Model } from "../src/types.ts";
|
| 51 |
+
|
| 52 |
+
const context: Context = {
|
| 53 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 54 |
+
};
|
| 55 |
+
|
| 56 |
+
async function captureClientConfig(
|
| 57 |
+
model: Model<"bedrock-converse-stream">,
|
| 58 |
+
options: BedrockOptions = {},
|
| 59 |
+
): Promise<Record<string, unknown>> {
|
| 60 |
+
bedrockMock.constructorCalls.length = 0;
|
| 61 |
+
await streamBedrock(model, context, { cacheRetention: "none", ...options }).result();
|
| 62 |
+
expect(bedrockMock.constructorCalls).toHaveLength(1);
|
| 63 |
+
return bedrockMock.constructorCalls[0];
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
describe("bedrock credential priority", () => {
|
| 67 |
+
afterEach(() => {
|
| 68 |
+
vi.unstubAllEnvs();
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it("prefers explicit and scoped profiles over ambient AWS access keys", async () => {
|
| 72 |
+
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE");
|
| 73 |
+
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "secretexample");
|
| 74 |
+
const model = getBuiltinModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 75 |
+
|
| 76 |
+
let config = await captureClientConfig(model, { profile: "explicit-profile" });
|
| 77 |
+
|
| 78 |
+
expect(config.profile).toBe("explicit-profile");
|
| 79 |
+
expect(config.credentials).toBeUndefined();
|
| 80 |
+
|
| 81 |
+
config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-profile" } });
|
| 82 |
+
|
| 83 |
+
expect(config.profile).toBe("scoped-profile");
|
| 84 |
+
expect(config.credentials).toBeUndefined();
|
| 85 |
+
});
|
| 86 |
+
|
| 87 |
+
it("uses ambient AWS access keys when no profile is configured", async () => {
|
| 88 |
+
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE");
|
| 89 |
+
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "secretexample");
|
| 90 |
+
const model = getBuiltinModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 91 |
+
|
| 92 |
+
const config = await captureClientConfig(model);
|
| 93 |
+
|
| 94 |
+
expect(config.profile).toBeUndefined();
|
| 95 |
+
expect(config.credentials).toEqual({
|
| 96 |
+
accessKeyId: "AKIAEXAMPLE",
|
| 97 |
+
secretAccessKey: "secretexample",
|
| 98 |
+
});
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
it("uses ambient AWS access keys when only an ambient profile is set", async () => {
|
| 102 |
+
vi.stubEnv("AWS_ACCESS_KEY_ID", "AKIAEXAMPLE");
|
| 103 |
+
vi.stubEnv("AWS_SECRET_ACCESS_KEY", "secretexample");
|
| 104 |
+
vi.stubEnv("AWS_PROFILE", "ambient-profile");
|
| 105 |
+
const model = getBuiltinModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 106 |
+
|
| 107 |
+
const config = await captureClientConfig(model);
|
| 108 |
+
|
| 109 |
+
expect(config.profile).toBe("ambient-profile");
|
| 110 |
+
expect(config.credentials).toEqual({
|
| 111 |
+
accessKeyId: "AKIAEXAMPLE",
|
| 112 |
+
secretAccessKey: "secretexample",
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
});
|
packages/ai/test/bedrock-custom-headers.test.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
type MiddlewareHandler = (next: (args: unknown) => Promise<unknown>) => (args: unknown) => Promise<unknown>;
|
| 4 |
+
|
| 5 |
+
const bedrockMock = vi.hoisted(() => ({
|
| 6 |
+
middlewareRegistrations: [] as Array<{
|
| 7 |
+
handler: MiddlewareHandler;
|
| 8 |
+
opts: { step?: string; name?: string; priority?: string };
|
| 9 |
+
}>,
|
| 10 |
+
}));
|
| 11 |
+
|
| 12 |
+
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
| 13 |
+
class BedrockRuntimeServiceException extends Error {}
|
| 14 |
+
|
| 15 |
+
class BedrockRuntimeClient {
|
| 16 |
+
middlewareStack = {
|
| 17 |
+
add: (handler: MiddlewareHandler, opts: { step?: string; name?: string; priority?: string }) => {
|
| 18 |
+
bedrockMock.middlewareRegistrations.push({ handler, opts });
|
| 19 |
+
},
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
send(): Promise<never> {
|
| 23 |
+
return Promise.reject(new Error("mock send"));
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
class ConverseStreamCommand {
|
| 28 |
+
readonly input: unknown;
|
| 29 |
+
|
| 30 |
+
constructor(input: unknown) {
|
| 31 |
+
this.input = input;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
return {
|
| 36 |
+
BedrockRuntimeClient,
|
| 37 |
+
BedrockRuntimeServiceException,
|
| 38 |
+
ConverseStreamCommand,
|
| 39 |
+
StopReason: {
|
| 40 |
+
END_TURN: "end_turn",
|
| 41 |
+
STOP_SEQUENCE: "stop_sequence",
|
| 42 |
+
MAX_TOKENS: "max_tokens",
|
| 43 |
+
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
| 44 |
+
TOOL_USE: "tool_use",
|
| 45 |
+
},
|
| 46 |
+
CachePointType: { DEFAULT: "default" },
|
| 47 |
+
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
| 48 |
+
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
| 49 |
+
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
| 50 |
+
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
| 51 |
+
};
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
|
| 55 |
+
import { stream as streamBedrock, streamSimple as streamSimpleBedrock } from "../src/api/bedrock-converse-stream.ts";
|
| 56 |
+
import { getModel } from "../src/compat.ts";
|
| 57 |
+
import type { Context, Model } from "../src/types.ts";
|
| 58 |
+
|
| 59 |
+
const context: Context = {
|
| 60 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const MIDDLEWARE_NAME = "pi-ai-custom-headers";
|
| 64 |
+
|
| 65 |
+
function getModelFixture(): Model<"bedrock-converse-stream"> {
|
| 66 |
+
return getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Drive a stream to completion so the middleware (registered before `client.send`)
|
| 71 |
+
* is captured even though the mocked `send()` rejects. Errors are swallowed because
|
| 72 |
+
* the rejecting mock is expected — we only care about the recorded registrations.
|
| 73 |
+
*/
|
| 74 |
+
async function driveBedrock(options: BedrockOptions): Promise<void> {
|
| 75 |
+
await streamBedrock(getModelFixture(), context, options)
|
| 76 |
+
.result()
|
| 77 |
+
.catch(() => undefined);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function findCustomHeadersRegistration() {
|
| 81 |
+
const matches = bedrockMock.middlewareRegistrations.filter((r) => r.opts.name === MIDDLEWARE_NAME);
|
| 82 |
+
return matches;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
beforeEach(() => {
|
| 86 |
+
bedrockMock.middlewareRegistrations.length = 0;
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
describe("bedrock custom headers middleware", () => {
|
| 90 |
+
it("VC1: registers a build-step middleware that injects the caller header (happy path)", async () => {
|
| 91 |
+
await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } });
|
| 92 |
+
|
| 93 |
+
const registrations = findCustomHeadersRegistration();
|
| 94 |
+
expect(registrations).toHaveLength(1);
|
| 95 |
+
|
| 96 |
+
const [reg] = registrations;
|
| 97 |
+
expect(reg.opts.step).toBe("build");
|
| 98 |
+
expect(reg.opts.priority).toBe("low");
|
| 99 |
+
expect(reg.opts.name).toBe(MIDDLEWARE_NAME);
|
| 100 |
+
|
| 101 |
+
const nextSpy = vi.fn(async (a: unknown) => a);
|
| 102 |
+
const fakeArgs = { request: { headers: {} as Record<string, string> } };
|
| 103 |
+
await reg.handler(nextSpy)(fakeArgs);
|
| 104 |
+
|
| 105 |
+
expect(fakeArgs.request.headers["x-custom"]).toBe("v");
|
| 106 |
+
expect(nextSpy).toHaveBeenCalledTimes(1);
|
| 107 |
+
expect(nextSpy).toHaveBeenCalledWith(fakeArgs);
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
it("VC2: skips reserved headers case-insensitively while applying allowed ones", async () => {
|
| 111 |
+
await driveBedrock({
|
| 112 |
+
cacheRetention: "none",
|
| 113 |
+
headers: {
|
| 114 |
+
authorization: "evil",
|
| 115 |
+
"x-amz-date": "evil",
|
| 116 |
+
"x-allowed": "ok",
|
| 117 |
+
Authorization: "evil2",
|
| 118 |
+
"X-Amz-Date": "evil2",
|
| 119 |
+
HOST: "evil3",
|
| 120 |
+
},
|
| 121 |
+
});
|
| 122 |
+
|
| 123 |
+
const [reg] = findCustomHeadersRegistration();
|
| 124 |
+
expect(reg).toBeDefined();
|
| 125 |
+
|
| 126 |
+
const nextSpy = vi.fn(async (a: unknown) => a);
|
| 127 |
+
const fakeArgs = {
|
| 128 |
+
request: {
|
| 129 |
+
headers: {
|
| 130 |
+
authorization: "real-auth",
|
| 131 |
+
"x-amz-date": "real-date",
|
| 132 |
+
host: "real-host",
|
| 133 |
+
} as Record<string, string>,
|
| 134 |
+
},
|
| 135 |
+
};
|
| 136 |
+
await reg.handler(nextSpy)(fakeArgs);
|
| 137 |
+
|
| 138 |
+
expect(fakeArgs.request.headers.authorization).toBe("real-auth");
|
| 139 |
+
expect(fakeArgs.request.headers["x-amz-date"]).toBe("real-date");
|
| 140 |
+
expect(fakeArgs.request.headers.host).toBe("real-host");
|
| 141 |
+
expect(fakeArgs.request.headers["x-allowed"]).toBe("ok");
|
| 142 |
+
// Mixed-case reserved keys must be skipped too: a case-sensitive guard would
|
| 143 |
+
// add them back as distinct capitalised keys. Assert no such leak occurred and
|
| 144 |
+
// that the only new key beyond the three pre-existing ones is `x-allowed`.
|
| 145 |
+
expect(fakeArgs.request.headers.Authorization).toBeUndefined();
|
| 146 |
+
expect(fakeArgs.request.headers["X-Amz-Date"]).toBeUndefined();
|
| 147 |
+
expect(fakeArgs.request.headers.HOST).toBeUndefined();
|
| 148 |
+
expect(Object.keys(fakeArgs.request.headers).sort()).toEqual(
|
| 149 |
+
["authorization", "host", "x-allowed", "x-amz-date"].sort(),
|
| 150 |
+
);
|
| 151 |
+
expect(nextSpy).toHaveBeenCalledTimes(1);
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("VC3: registers no middleware when headers is undefined", async () => {
|
| 155 |
+
await driveBedrock({ cacheRetention: "none" });
|
| 156 |
+
|
| 157 |
+
expect(findCustomHeadersRegistration()).toHaveLength(0);
|
| 158 |
+
});
|
| 159 |
+
|
| 160 |
+
it("VC3: registers no middleware when headers is empty", async () => {
|
| 161 |
+
await driveBedrock({ cacheRetention: "none", headers: {} });
|
| 162 |
+
|
| 163 |
+
expect(findCustomHeadersRegistration()).toHaveLength(0);
|
| 164 |
+
});
|
| 165 |
+
|
| 166 |
+
it("VC3 (structural guard): passes through unchanged when the request has no headers", async () => {
|
| 167 |
+
await driveBedrock({ cacheRetention: "none", headers: { "x-custom": "v" } });
|
| 168 |
+
|
| 169 |
+
const [reg] = findCustomHeadersRegistration();
|
| 170 |
+
expect(reg).toBeDefined();
|
| 171 |
+
|
| 172 |
+
const nextSpy = vi.fn(async (a: unknown) => a);
|
| 173 |
+
|
| 174 |
+
const argsNoHeaders = { request: {} };
|
| 175 |
+
await expect(reg.handler(nextSpy)(argsNoHeaders)).resolves.toBeDefined();
|
| 176 |
+
expect(nextSpy).toHaveBeenCalledWith(argsNoHeaders);
|
| 177 |
+
|
| 178 |
+
const argsUndefinedRequest = { request: undefined };
|
| 179 |
+
await expect(reg.handler(nextSpy)(argsUndefinedRequest)).resolves.toBeDefined();
|
| 180 |
+
expect(nextSpy).toHaveBeenCalledWith(argsUndefinedRequest);
|
| 181 |
+
|
| 182 |
+
expect(nextSpy).toHaveBeenCalledTimes(2);
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
it("VC4: streamSimpleBedrock forwards headers end-to-end (regression guard)", async () => {
|
| 186 |
+
await streamSimpleBedrock(getModelFixture(), context, { headers: { "x-custom": "v" } })
|
| 187 |
+
.result()
|
| 188 |
+
.catch(() => undefined);
|
| 189 |
+
|
| 190 |
+
const registrations = findCustomHeadersRegistration();
|
| 191 |
+
expect(registrations).toHaveLength(1);
|
| 192 |
+
|
| 193 |
+
const [reg] = registrations;
|
| 194 |
+
expect(reg.opts.step).toBe("build");
|
| 195 |
+
|
| 196 |
+
const nextSpy = vi.fn(async (a: unknown) => a);
|
| 197 |
+
const fakeArgs = { request: { headers: {} as Record<string, string> } };
|
| 198 |
+
await reg.handler(nextSpy)(fakeArgs);
|
| 199 |
+
|
| 200 |
+
expect(fakeArgs.request.headers["x-custom"]).toBe("v");
|
| 201 |
+
});
|
| 202 |
+
});
|
packages/ai/test/bedrock-endpoint-resolution.test.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
const bedrockMock = vi.hoisted(() => ({
|
| 4 |
+
constructorCalls: [] as Array<Record<string, unknown>>,
|
| 5 |
+
}));
|
| 6 |
+
|
| 7 |
+
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
| 8 |
+
class BedrockRuntimeServiceException extends Error {}
|
| 9 |
+
|
| 10 |
+
class BedrockRuntimeClient {
|
| 11 |
+
constructor(config: Record<string, unknown>) {
|
| 12 |
+
bedrockMock.constructorCalls.push(config);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
send(): Promise<never> {
|
| 16 |
+
return Promise.reject(new Error("mock send"));
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
class ConverseStreamCommand {
|
| 21 |
+
readonly input: unknown;
|
| 22 |
+
|
| 23 |
+
constructor(input: unknown) {
|
| 24 |
+
this.input = input;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
return {
|
| 29 |
+
BedrockRuntimeClient,
|
| 30 |
+
BedrockRuntimeServiceException,
|
| 31 |
+
ConverseStreamCommand,
|
| 32 |
+
StopReason: {
|
| 33 |
+
END_TURN: "end_turn",
|
| 34 |
+
STOP_SEQUENCE: "stop_sequence",
|
| 35 |
+
MAX_TOKENS: "max_tokens",
|
| 36 |
+
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
| 37 |
+
TOOL_USE: "tool_use",
|
| 38 |
+
},
|
| 39 |
+
CachePointType: { DEFAULT: "default" },
|
| 40 |
+
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
| 41 |
+
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
| 42 |
+
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
| 43 |
+
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
| 44 |
+
};
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
import type { BedrockOptions } from "../src/api/bedrock-converse-stream.ts";
|
| 48 |
+
import { getModel, stream as streamBedrock } from "../src/compat.ts";
|
| 49 |
+
import type { Context, Model } from "../src/types.ts";
|
| 50 |
+
|
| 51 |
+
const context: Context = {
|
| 52 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const originalAwsRegion = process.env.AWS_REGION;
|
| 56 |
+
const originalAwsDefaultRegion = process.env.AWS_DEFAULT_REGION;
|
| 57 |
+
const originalAwsProfile = process.env.AWS_PROFILE;
|
| 58 |
+
|
| 59 |
+
beforeEach(() => {
|
| 60 |
+
bedrockMock.constructorCalls.length = 0;
|
| 61 |
+
delete process.env.AWS_REGION;
|
| 62 |
+
delete process.env.AWS_DEFAULT_REGION;
|
| 63 |
+
delete process.env.AWS_PROFILE;
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
afterEach(() => {
|
| 67 |
+
if (originalAwsRegion === undefined) {
|
| 68 |
+
delete process.env.AWS_REGION;
|
| 69 |
+
} else {
|
| 70 |
+
process.env.AWS_REGION = originalAwsRegion;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
if (originalAwsDefaultRegion === undefined) {
|
| 74 |
+
delete process.env.AWS_DEFAULT_REGION;
|
| 75 |
+
} else {
|
| 76 |
+
process.env.AWS_DEFAULT_REGION = originalAwsDefaultRegion;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (originalAwsProfile === undefined) {
|
| 80 |
+
delete process.env.AWS_PROFILE;
|
| 81 |
+
} else {
|
| 82 |
+
process.env.AWS_PROFILE = originalAwsProfile;
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
async function captureClientConfig(
|
| 87 |
+
model: Model<"bedrock-converse-stream">,
|
| 88 |
+
options: BedrockOptions = {},
|
| 89 |
+
): Promise<Record<string, unknown>> {
|
| 90 |
+
bedrockMock.constructorCalls.length = 0;
|
| 91 |
+
await streamBedrock(model, context, { cacheRetention: "none", ...options }).result();
|
| 92 |
+
expect(bedrockMock.constructorCalls).toHaveLength(1);
|
| 93 |
+
return bedrockMock.constructorCalls[0];
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
describe("bedrock endpoint resolution", () => {
|
| 97 |
+
it("assigns eu-central-1 runtime URLs to built-in EU inference profiles", () => {
|
| 98 |
+
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 99 |
+
|
| 100 |
+
expect(model.baseUrl).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
it("does not pin standard AWS endpoints when AWS_REGION is configured", async () => {
|
| 104 |
+
process.env.AWS_REGION = "us-east-2";
|
| 105 |
+
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 106 |
+
|
| 107 |
+
const config = await captureClientConfig(model);
|
| 108 |
+
|
| 109 |
+
expect(config.region).toBe("us-east-2");
|
| 110 |
+
expect(config.endpoint).toBeUndefined();
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
it("derives region from a built-in EU endpoint when no region or profile is configured", async () => {
|
| 114 |
+
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 115 |
+
|
| 116 |
+
const config = await captureClientConfig(model);
|
| 117 |
+
|
| 118 |
+
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
|
| 119 |
+
expect(config.region).toBe("eu-central-1");
|
| 120 |
+
});
|
| 121 |
+
|
| 122 |
+
it("handles missing regions for explicit, scoped, and ambient profiles", async () => {
|
| 123 |
+
const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 124 |
+
|
| 125 |
+
let config = await captureClientConfig(model, { profile: "bedrock-profile" });
|
| 126 |
+
|
| 127 |
+
expect(config.profile).toBe("bedrock-profile");
|
| 128 |
+
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
|
| 129 |
+
expect(config.region).toBe("eu-central-1");
|
| 130 |
+
|
| 131 |
+
config = await captureClientConfig(model, { env: { AWS_PROFILE: "scoped-bedrock-profile" } });
|
| 132 |
+
|
| 133 |
+
expect(config.profile).toBe("scoped-bedrock-profile");
|
| 134 |
+
expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com");
|
| 135 |
+
expect(config.region).toBe("eu-central-1");
|
| 136 |
+
|
| 137 |
+
process.env.AWS_PROFILE = "ambient-bedrock-profile";
|
| 138 |
+
config = await captureClientConfig(model);
|
| 139 |
+
|
| 140 |
+
expect(config.profile).toBe("ambient-bedrock-profile");
|
| 141 |
+
expect(config.endpoint).toBeUndefined();
|
| 142 |
+
expect(config.region).toBeUndefined();
|
| 143 |
+
});
|
| 144 |
+
|
| 145 |
+
it("still passes custom Bedrock endpoints through to the SDK client", async () => {
|
| 146 |
+
process.env.AWS_REGION = "us-west-2";
|
| 147 |
+
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 148 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 149 |
+
...baseModel,
|
| 150 |
+
baseUrl: "https://bedrock-vpc.example.com",
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const config = await captureClientConfig(model);
|
| 154 |
+
|
| 155 |
+
expect(config.endpoint).toBe("https://bedrock-vpc.example.com");
|
| 156 |
+
expect(config.region).toBe("us-west-2");
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
it("extracts region from inference profile ARN regardless of AWS_REGION", async () => {
|
| 160 |
+
process.env.AWS_REGION = "us-east-1";
|
| 161 |
+
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 162 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 163 |
+
...baseModel,
|
| 164 |
+
id: "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123",
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
const config = await captureClientConfig(model);
|
| 168 |
+
|
| 169 |
+
expect(config.region).toBe("us-west-2");
|
| 170 |
+
});
|
| 171 |
+
|
| 172 |
+
it("extracts region from GovCloud inference profile ARN", async () => {
|
| 173 |
+
process.env.AWS_REGION = "us-east-1";
|
| 174 |
+
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 175 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 176 |
+
...baseModel,
|
| 177 |
+
id: "arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:application-inference-profile/abc123",
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
const config = await captureClientConfig(model);
|
| 181 |
+
|
| 182 |
+
expect(config.region).toBe("us-gov-west-1");
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
it("preserves ambient AWS auth for custom model IDs through compat dispatch", async () => {
|
| 186 |
+
process.env.AWS_PROFILE = "bedrock-profile";
|
| 187 |
+
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 188 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 189 |
+
...baseModel,
|
| 190 |
+
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/example",
|
| 191 |
+
};
|
| 192 |
+
|
| 193 |
+
const config = await captureClientConfig(model);
|
| 194 |
+
|
| 195 |
+
expect(config.profile).toBe("bedrock-profile");
|
| 196 |
+
expect(config.token).toBeUndefined();
|
| 197 |
+
expect(config.authSchemePreference).toBeUndefined();
|
| 198 |
+
});
|
| 199 |
+
|
| 200 |
+
it("uses the generic API key option as a Bedrock bearer token", async () => {
|
| 201 |
+
const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-8");
|
| 202 |
+
|
| 203 |
+
const config = await captureClientConfig(model, { apiKey: "bedrock-api-key" });
|
| 204 |
+
|
| 205 |
+
expect(config.token).toEqual({ token: "bedrock-api-key" });
|
| 206 |
+
expect(config.authSchemePreference).toEqual(["httpBearerAuth"]);
|
| 207 |
+
});
|
| 208 |
+
});
|
packages/ai/test/bedrock-models.test.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* A test suite to ensure all configured Amazon Bedrock models are usable.
|
| 3 |
+
*
|
| 4 |
+
* This is here to make sure we got correct model identifiers from models.dev and other sources.
|
| 5 |
+
* Because Amazon Bedrock requires cross-region inference in some models,
|
| 6 |
+
* plain model identifiers are not always usable and it requires tweaking of model identifiers to use cross-region inference.
|
| 7 |
+
* See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system for more details.
|
| 8 |
+
*
|
| 9 |
+
* This test suite is not enabled by default unless AWS credentials and `BEDROCK_EXTENSIVE_MODEL_TEST` environment variables are set.
|
| 10 |
+
* This test suite takes ~2 minutes to run. Because not all models are available in all regions,
|
| 11 |
+
* it's recommended to use `us-west-2` region for best coverage for running this test suite.
|
| 12 |
+
*
|
| 13 |
+
* You can run this test suite with:
|
| 14 |
+
* ```bash
|
| 15 |
+
* $ AWS_REGION=us-west-2 BEDROCK_EXTENSIVE_MODEL_TEST=1 AWS_PROFILE=... npm test -- ./test/bedrock-models.test.ts
|
| 16 |
+
* ```
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
import { describe, expect, it } from "vitest";
|
| 20 |
+
import { complete, getModels } from "../src/compat.ts";
|
| 21 |
+
import type { Context } from "../src/types.ts";
|
| 22 |
+
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
| 23 |
+
|
| 24 |
+
describe("Amazon Bedrock Models", () => {
|
| 25 |
+
const models = getModels("amazon-bedrock");
|
| 26 |
+
|
| 27 |
+
it("should get all available Bedrock models", () => {
|
| 28 |
+
expect(models.length).toBeGreaterThan(0);
|
| 29 |
+
console.log(`Found ${models.length} Bedrock models`);
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
it("exposes Claude Opus 5 through an inference profile only", () => {
|
| 33 |
+
expect(models.some((model) => model.id === "global.anthropic.claude-opus-5")).toBe(true);
|
| 34 |
+
expect(models.some((model) => model.id === "anthropic.claude-opus-5")).toBe(false);
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
if (hasBedrockCredentials() && process.env.BEDROCK_EXTENSIVE_MODEL_TEST) {
|
| 38 |
+
for (const model of models) {
|
| 39 |
+
it(`should make a simple request with ${model.id}`, { timeout: 10_000 }, async () => {
|
| 40 |
+
const context: Context = {
|
| 41 |
+
systemPrompt: "You are a helpful assistant. Be extremely concise.",
|
| 42 |
+
messages: [
|
| 43 |
+
{
|
| 44 |
+
role: "user",
|
| 45 |
+
content: "Reply with exactly: 'OK'",
|
| 46 |
+
timestamp: Date.now(),
|
| 47 |
+
},
|
| 48 |
+
],
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const response = await complete(model, context);
|
| 52 |
+
|
| 53 |
+
expect(response.role).toBe("assistant");
|
| 54 |
+
expect(response.content).toBeTruthy();
|
| 55 |
+
expect(response.content.length).toBeGreaterThan(0);
|
| 56 |
+
expect(response.usage.input + response.usage.cacheRead).toBeGreaterThan(0);
|
| 57 |
+
expect(response.usage.output).toBeGreaterThan(0);
|
| 58 |
+
expect(response.errorMessage).toBeFalsy();
|
| 59 |
+
|
| 60 |
+
const textContent = response.content
|
| 61 |
+
.filter((b) => b.type === "text")
|
| 62 |
+
.map((b) => (b.type === "text" ? b.text : ""))
|
| 63 |
+
.join("")
|
| 64 |
+
.trim();
|
| 65 |
+
expect(textContent).toBeTruthy();
|
| 66 |
+
console.log(`${model.id}: ${textContent.substring(0, 100)}`);
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
});
|
packages/ai/test/bedrock-redacted-reasoning.test.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* OpenAI models served through Bedrock Converse (e.g. `global.openai.gpt-5.6-terra`)
|
| 5 |
+
* return encrypted reasoning as the opaque `redactedContent` member of
|
| 6 |
+
* `reasoningContent`, not as `reasoningText`. The AWS SDK decodes the wire blob to
|
| 7 |
+
* `Uint8Array`.
|
| 8 |
+
* @see https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ReasoningContentBlockDelta.html
|
| 9 |
+
*/
|
| 10 |
+
const bedrockMock = vi.hoisted(() => {
|
| 11 |
+
const redactedBase64 = "cnNuXzVaVnJpZjRKMGJYSXFtV2RsZWRqN1FJRmVOaWtSUWJF";
|
| 12 |
+
return {
|
| 13 |
+
redactedBase64,
|
| 14 |
+
redactedBytes: new Uint8Array(Buffer.from(redactedBase64, "base64")),
|
| 15 |
+
streamEvents: undefined as unknown[] | undefined,
|
| 16 |
+
};
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
| 20 |
+
class BedrockRuntimeServiceException extends Error {}
|
| 21 |
+
|
| 22 |
+
class BedrockRuntimeClient {
|
| 23 |
+
send(): Promise<unknown> {
|
| 24 |
+
if (bedrockMock.streamEvents) {
|
| 25 |
+
const events = bedrockMock.streamEvents;
|
| 26 |
+
return Promise.resolve({
|
| 27 |
+
$metadata: { httpStatusCode: 200 },
|
| 28 |
+
stream: (async function* () {
|
| 29 |
+
yield* events;
|
| 30 |
+
})(),
|
| 31 |
+
});
|
| 32 |
+
}
|
| 33 |
+
return Promise.reject(new Error("mock send"));
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
class ConverseStreamCommand {
|
| 38 |
+
readonly input: unknown;
|
| 39 |
+
|
| 40 |
+
constructor(input: unknown) {
|
| 41 |
+
this.input = input;
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
BedrockRuntimeClient,
|
| 47 |
+
BedrockRuntimeServiceException,
|
| 48 |
+
ConverseStreamCommand,
|
| 49 |
+
StopReason: {
|
| 50 |
+
END_TURN: "end_turn",
|
| 51 |
+
STOP_SEQUENCE: "stop_sequence",
|
| 52 |
+
MAX_TOKENS: "max_tokens",
|
| 53 |
+
MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded",
|
| 54 |
+
TOOL_USE: "tool_use",
|
| 55 |
+
},
|
| 56 |
+
CachePointType: { DEFAULT: "default" },
|
| 57 |
+
CacheTTL: { ONE_HOUR: "ONE_HOUR" },
|
| 58 |
+
ConversationRole: { ASSISTANT: "assistant", USER: "user" },
|
| 59 |
+
ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" },
|
| 60 |
+
ToolResultStatus: { ERROR: "error", SUCCESS: "success" },
|
| 61 |
+
};
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
| 65 |
+
import type { Context, Message, Model, ThinkingContent } from "../src/types.ts";
|
| 66 |
+
|
| 67 |
+
const gptModel: Model<"bedrock-converse-stream"> = {
|
| 68 |
+
id: "global.openai.gpt-5.6-terra",
|
| 69 |
+
name: "GPT-5.6 Terra (Global)",
|
| 70 |
+
api: "bedrock-converse-stream",
|
| 71 |
+
provider: "amazon-bedrock",
|
| 72 |
+
baseUrl: "https://bedrock-runtime.ap-northeast-1.amazonaws.com",
|
| 73 |
+
reasoning: true,
|
| 74 |
+
input: ["text"],
|
| 75 |
+
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 },
|
| 76 |
+
contextWindow: 400000,
|
| 77 |
+
maxTokens: 128000,
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const emptyUsage = {
|
| 81 |
+
input: 0,
|
| 82 |
+
output: 0,
|
| 83 |
+
cacheRead: 0,
|
| 84 |
+
cacheWrite: 0,
|
| 85 |
+
totalTokens: 0,
|
| 86 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
/** Mirrors the ConverseStream frames GPT-5.6 emits: encrypted reasoning, then text. */
|
| 90 |
+
function redactedReasoningEvents(): unknown[] {
|
| 91 |
+
return [
|
| 92 |
+
{ messageStart: { role: "assistant" } },
|
| 93 |
+
{
|
| 94 |
+
contentBlockDelta: {
|
| 95 |
+
contentBlockIndex: 0,
|
| 96 |
+
delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
|
| 97 |
+
},
|
| 98 |
+
},
|
| 99 |
+
{ contentBlockStop: { contentBlockIndex: 0 } },
|
| 100 |
+
{ contentBlockDelta: { contentBlockIndex: 1, delta: { text: "done" } } },
|
| 101 |
+
{ contentBlockStop: { contentBlockIndex: 1 } },
|
| 102 |
+
{ messageStop: { stopReason: "end_turn" } },
|
| 103 |
+
];
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
interface BedrockRequestPayload {
|
| 107 |
+
messages: Array<{ role: string; content: Array<Record<string, unknown>> }>;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async function capturePayload(context: Context): Promise<BedrockRequestPayload> {
|
| 111 |
+
let capturedPayload: BedrockRequestPayload | undefined;
|
| 112 |
+
const s = streamBedrock(gptModel, context, {
|
| 113 |
+
cacheRetention: "none",
|
| 114 |
+
signal: AbortSignal.abort(),
|
| 115 |
+
onPayload: (payload) => {
|
| 116 |
+
capturedPayload = payload as BedrockRequestPayload;
|
| 117 |
+
return payload;
|
| 118 |
+
},
|
| 119 |
+
});
|
| 120 |
+
for await (const event of s) {
|
| 121 |
+
if (event.type === "error") break;
|
| 122 |
+
}
|
| 123 |
+
if (!capturedPayload) {
|
| 124 |
+
throw new Error("Expected Bedrock payload to be captured before request abort");
|
| 125 |
+
}
|
| 126 |
+
return capturedPayload;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
describe("Bedrock redacted reasoning", () => {
|
| 130 |
+
beforeEach(() => {
|
| 131 |
+
bedrockMock.streamEvents = undefined;
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
it("does not fail the stream when reasoning arrives as redactedContent", async () => {
|
| 135 |
+
bedrockMock.streamEvents = redactedReasoningEvents();
|
| 136 |
+
|
| 137 |
+
const response = await streamBedrock(gptModel, {
|
| 138 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 139 |
+
}).result();
|
| 140 |
+
|
| 141 |
+
expect(response.stopReason, response.errorMessage).not.toBe("error");
|
| 142 |
+
// Reasoning precedes the answer, matching the order Bedrock streamed it.
|
| 143 |
+
expect(response.content.map((c) => c.type)).toEqual(["thinking", "text"]);
|
| 144 |
+
expect(response.content[1]).toEqual({ type: "text", text: "done" });
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
it("preserves the encrypted reasoning payload on the assistant message", async () => {
|
| 148 |
+
bedrockMock.streamEvents = redactedReasoningEvents();
|
| 149 |
+
|
| 150 |
+
const response = await streamBedrock(gptModel, {
|
| 151 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 152 |
+
}).result();
|
| 153 |
+
|
| 154 |
+
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
|
| 155 |
+
expect(thinking).toBeDefined();
|
| 156 |
+
// Same representation Anthropic redacted thinking already uses: the opaque
|
| 157 |
+
// payload rides in `thinkingSignature` with `redacted: true`.
|
| 158 |
+
expect(thinking?.redacted).toBe(true);
|
| 159 |
+
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
|
| 160 |
+
// The byte buffer is streaming scratch state: persisting it would bloat the
|
| 161 |
+
// session, since a Uint8Array serializes to an index-keyed object.
|
| 162 |
+
expect("redactedChunks" in thinking!).toBe(false);
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
it("encodes the payload when the stream never sends contentBlockStop", async () => {
|
| 166 |
+
bedrockMock.streamEvents = [
|
| 167 |
+
{ messageStart: { role: "assistant" } },
|
| 168 |
+
{
|
| 169 |
+
contentBlockDelta: {
|
| 170 |
+
contentBlockIndex: 0,
|
| 171 |
+
delta: { reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
|
| 172 |
+
},
|
| 173 |
+
},
|
| 174 |
+
{ messageStop: { stopReason: "end_turn" } },
|
| 175 |
+
];
|
| 176 |
+
|
| 177 |
+
const response = await streamBedrock(gptModel, {
|
| 178 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 179 |
+
}).result();
|
| 180 |
+
|
| 181 |
+
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
|
| 182 |
+
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
|
| 183 |
+
// Streaming scratch state must not survive into the persisted message.
|
| 184 |
+
expect("redactedChunks" in thinking!).toBe(false);
|
| 185 |
+
expect("index" in thinking!).toBe(false);
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
it("joins encrypted reasoning split across deltas", async () => {
|
| 189 |
+
const [head, tail] = [bedrockMock.redactedBytes.slice(0, 7), bedrockMock.redactedBytes.slice(7)];
|
| 190 |
+
bedrockMock.streamEvents = [
|
| 191 |
+
{ messageStart: { role: "assistant" } },
|
| 192 |
+
{ contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: head } } } },
|
| 193 |
+
{ contentBlockDelta: { contentBlockIndex: 0, delta: { reasoningContent: { redactedContent: tail } } } },
|
| 194 |
+
{ contentBlockStop: { contentBlockIndex: 0 } },
|
| 195 |
+
{ messageStop: { stopReason: "end_turn" } },
|
| 196 |
+
];
|
| 197 |
+
|
| 198 |
+
const response = await streamBedrock(gptModel, {
|
| 199 |
+
messages: [{ role: "user", content: "hello", timestamp: Date.now() }],
|
| 200 |
+
}).result();
|
| 201 |
+
|
| 202 |
+
const thinking = response.content.find((c): c is ThinkingContent => c.type === "thinking");
|
| 203 |
+
expect(thinking?.thinkingSignature).toBe(bedrockMock.redactedBase64);
|
| 204 |
+
// The placeholder marks the block once, not once per delta.
|
| 205 |
+
expect(thinking?.thinking).toBe("[Reasoning redacted]");
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
it("replays redacted reasoning as reasoningContent.redactedContent", async () => {
|
| 209 |
+
const messages: Message[] = [
|
| 210 |
+
{ role: "user", content: "hello", timestamp: Date.now() },
|
| 211 |
+
{
|
| 212 |
+
role: "assistant",
|
| 213 |
+
content: [
|
| 214 |
+
{ type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true },
|
| 215 |
+
{ type: "text", text: "done" },
|
| 216 |
+
],
|
| 217 |
+
api: "bedrock-converse-stream",
|
| 218 |
+
provider: "amazon-bedrock",
|
| 219 |
+
model: gptModel.id,
|
| 220 |
+
usage: emptyUsage,
|
| 221 |
+
stopReason: "stop",
|
| 222 |
+
timestamp: Date.now(),
|
| 223 |
+
},
|
| 224 |
+
{ role: "user", content: "continue", timestamp: Date.now() },
|
| 225 |
+
];
|
| 226 |
+
|
| 227 |
+
const payload = await capturePayload({ messages });
|
| 228 |
+
|
| 229 |
+
const assistant = payload.messages.find((m: any) => m.role === "assistant");
|
| 230 |
+
expect(assistant).toBeDefined();
|
| 231 |
+
expect(assistant!.content).toEqual([
|
| 232 |
+
{ reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
|
| 233 |
+
{ text: "done" },
|
| 234 |
+
]);
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
it("replays redacted reasoning before the toolUse block it belongs to", async () => {
|
| 238 |
+
// Bedrock rejects a tool continuation whose reasoning block is missing or
|
| 239 |
+
// reordered, so the opaque payload must land ahead of the matching toolUse.
|
| 240 |
+
const messages: Message[] = [
|
| 241 |
+
{ role: "user", content: "read the file", timestamp: Date.now() },
|
| 242 |
+
{
|
| 243 |
+
role: "assistant",
|
| 244 |
+
content: [
|
| 245 |
+
{ type: "thinking", thinking: "", thinkingSignature: bedrockMock.redactedBase64, redacted: true },
|
| 246 |
+
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "/tmp/a.txt" } },
|
| 247 |
+
],
|
| 248 |
+
api: "bedrock-converse-stream",
|
| 249 |
+
provider: "amazon-bedrock",
|
| 250 |
+
model: gptModel.id,
|
| 251 |
+
usage: emptyUsage,
|
| 252 |
+
stopReason: "toolUse",
|
| 253 |
+
timestamp: Date.now(),
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
role: "toolResult",
|
| 257 |
+
toolCallId: "tool-1",
|
| 258 |
+
toolName: "read",
|
| 259 |
+
content: [{ type: "text", text: "file body" }],
|
| 260 |
+
isError: false,
|
| 261 |
+
timestamp: Date.now(),
|
| 262 |
+
},
|
| 263 |
+
];
|
| 264 |
+
|
| 265 |
+
const payload = await capturePayload({ messages });
|
| 266 |
+
|
| 267 |
+
const assistant = payload.messages.find((m: any) => m.role === "assistant");
|
| 268 |
+
expect(assistant).toBeDefined();
|
| 269 |
+
expect(assistant!.content).toEqual([
|
| 270 |
+
{ reasoningContent: { redactedContent: bedrockMock.redactedBytes } },
|
| 271 |
+
{ toolUse: { toolUseId: "tool-1", name: "read", input: { path: "/tmp/a.txt" } } },
|
| 272 |
+
]);
|
| 273 |
+
});
|
| 274 |
+
});
|
packages/ai/test/bedrock-response-headers.test.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createServer, type Server } from "node:http";
|
| 2 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 3 |
+
import { stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
| 4 |
+
import { getModel } from "../src/compat.ts";
|
| 5 |
+
import type { Model, ProviderResponse } from "../src/types.ts";
|
| 6 |
+
|
| 7 |
+
const MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001-v1:0";
|
| 8 |
+
|
| 9 |
+
let server: Server | undefined;
|
| 10 |
+
|
| 11 |
+
afterEach(async () => {
|
| 12 |
+
if (!server) return;
|
| 13 |
+
await new Promise<void>((resolve, reject) => {
|
| 14 |
+
server?.close((error) => (error ? reject(error) : resolve()));
|
| 15 |
+
});
|
| 16 |
+
server = undefined;
|
| 17 |
+
});
|
| 18 |
+
|
| 19 |
+
async function startBedrockResponseServer(): Promise<string> {
|
| 20 |
+
server = createServer((_req, res) => {
|
| 21 |
+
res.writeHead(200, {
|
| 22 |
+
"content-type": "application/vnd.amazon.eventstream",
|
| 23 |
+
"x-bifrost-provider": "bedrock",
|
| 24 |
+
"x-bifrost-resolved-model": MODEL_ID,
|
| 25 |
+
"x-amzn-requestid": "req-123",
|
| 26 |
+
});
|
| 27 |
+
res.end();
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
await new Promise<void>((resolve) => server?.listen(0, "127.0.0.1", resolve));
|
| 31 |
+
const address = server.address();
|
| 32 |
+
if (!address || typeof address === "string") throw new Error("Expected TCP test server address");
|
| 33 |
+
return `http://127.0.0.1:${address.port}`;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
describe("bedrock response headers", () => {
|
| 37 |
+
it("forwards raw Smithy response headers to onResponse", async () => {
|
| 38 |
+
const baseModel = getModel("amazon-bedrock", MODEL_ID) as Model<"bedrock-converse-stream">;
|
| 39 |
+
const model = { ...baseModel, baseUrl: await startBedrockResponseServer() };
|
| 40 |
+
const responses: ProviderResponse[] = [];
|
| 41 |
+
|
| 42 |
+
const result = await streamBedrock(
|
| 43 |
+
model,
|
| 44 |
+
{ messages: [{ role: "user", content: "hello", timestamp: Date.now() }] },
|
| 45 |
+
{
|
| 46 |
+
cacheRetention: "none",
|
| 47 |
+
env: { AWS_BEDROCK_FORCE_HTTP1: "1", AWS_BEDROCK_SKIP_AUTH: "1" },
|
| 48 |
+
onResponse: (response) => {
|
| 49 |
+
responses.push(response);
|
| 50 |
+
},
|
| 51 |
+
},
|
| 52 |
+
).result();
|
| 53 |
+
|
| 54 |
+
// The fake server intentionally returns an empty event stream; this assertion
|
| 55 |
+
// documents that the header callback still fires before stream consumption.
|
| 56 |
+
expect(result.stopReason).toBe("error");
|
| 57 |
+
expect(responses).toHaveLength(1);
|
| 58 |
+
expect(responses[0].status).toBe(200);
|
| 59 |
+
expect(responses[0].headers["x-amzn-requestid"]).toBe("req-123");
|
| 60 |
+
expect(responses[0].headers["x-bifrost-provider"]).toBe("bedrock");
|
| 61 |
+
expect(responses[0].headers["x-bifrost-resolved-model"]).toBe(MODEL_ID);
|
| 62 |
+
});
|
| 63 |
+
});
|
packages/ai/test/bedrock-thinking-payload.test.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { type BedrockOptions, stream as streamBedrock } from "../src/api/bedrock-converse-stream.ts";
|
| 3 |
+
import { getModel } from "../src/compat.ts";
|
| 4 |
+
import type { Context, Model } from "../src/types.ts";
|
| 5 |
+
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
| 6 |
+
|
| 7 |
+
interface BedrockThinkingPayload {
|
| 8 |
+
additionalModelRequestFields?: {
|
| 9 |
+
thinking?: { type: string; budget_tokens?: number; display?: string };
|
| 10 |
+
output_config?: { effort?: string };
|
| 11 |
+
anthropic_beta?: string[];
|
| 12 |
+
};
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
class PayloadCaptured extends Error {
|
| 16 |
+
constructor() {
|
| 17 |
+
super("payload captured");
|
| 18 |
+
this.name = "PayloadCaptured";
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function makeContext(): Context {
|
| 23 |
+
return {
|
| 24 |
+
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
| 25 |
+
};
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
async function capturePayload(
|
| 29 |
+
model: Model<"bedrock-converse-stream">,
|
| 30 |
+
options?: BedrockOptions,
|
| 31 |
+
): Promise<BedrockThinkingPayload> {
|
| 32 |
+
let capturedPayload: BedrockThinkingPayload | undefined;
|
| 33 |
+
const s = streamBedrock(model, makeContext(), {
|
| 34 |
+
...options,
|
| 35 |
+
reasoning: options?.reasoning ?? "high",
|
| 36 |
+
onPayload: (payload) => {
|
| 37 |
+
capturedPayload = payload as BedrockThinkingPayload;
|
| 38 |
+
throw new PayloadCaptured();
|
| 39 |
+
},
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
for await (const event of s) {
|
| 43 |
+
if (event.type === "error") {
|
| 44 |
+
break;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
if (!capturedPayload) {
|
| 49 |
+
throw new Error("Expected Bedrock payload to be captured before request abort");
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
return capturedPayload;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
describe("Bedrock thinking payload", () => {
|
| 56 |
+
it("uses adaptive thinking for Claude Opus 4.8 when reasoning is enabled", async () => {
|
| 57 |
+
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
| 58 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 59 |
+
...baseModel,
|
| 60 |
+
id: "global.anthropic.claude-opus-4-8-v1",
|
| 61 |
+
name: "Claude Opus 4.8 (Global)",
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
const payload = await capturePayload(model);
|
| 65 |
+
|
| 66 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 67 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
| 68 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it("maps xhigh reasoning to effort=xhigh for Claude Opus 4.8", async () => {
|
| 72 |
+
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
| 73 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 74 |
+
...baseModel,
|
| 75 |
+
id: "global.anthropic.claude-opus-4-8-v1",
|
| 76 |
+
name: "Claude Opus 4.8 (Global)",
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
+
const payload = await capturePayload(model, { reasoning: "xhigh" });
|
| 80 |
+
|
| 81 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 82 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
|
| 83 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => {
|
| 87 |
+
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
|
| 88 |
+
|
| 89 |
+
const payload = await capturePayload(model);
|
| 90 |
+
|
| 91 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 92 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
| 93 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => {
|
| 97 |
+
const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-5");
|
| 98 |
+
|
| 99 |
+
const payload = await capturePayload(model);
|
| 100 |
+
|
| 101 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 102 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
| 103 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => {
|
| 107 |
+
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
|
| 108 |
+
|
| 109 |
+
const payload = await capturePayload(model);
|
| 110 |
+
|
| 111 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 112 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
| 113 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it("maps xhigh reasoning to effort=xhigh for Claude Opus 5", async () => {
|
| 117 |
+
const model = getModel("amazon-bedrock", "global.anthropic.claude-opus-5");
|
| 118 |
+
|
| 119 |
+
const payload = await capturePayload(model, { reasoning: "xhigh" });
|
| 120 |
+
|
| 121 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 122 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
|
| 123 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
it("maps xhigh reasoning to effort=xhigh for Claude Fable 5", async () => {
|
| 127 |
+
const model = getModel("amazon-bedrock", "global.anthropic.claude-fable-5");
|
| 128 |
+
|
| 129 |
+
const payload = await capturePayload(model, { reasoning: "xhigh" });
|
| 130 |
+
|
| 131 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 132 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "xhigh" });
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
it("omits display for GovCloud model ids on non-adaptive Claude thinking", async () => {
|
| 136 |
+
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 137 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 138 |
+
...baseModel,
|
| 139 |
+
id: "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
| 140 |
+
name: "Claude Sonnet 4.5 (GovCloud)",
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
const payload = await capturePayload(model);
|
| 144 |
+
|
| 145 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "enabled", budget_tokens: 16384 });
|
| 146 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual(["interleaved-thinking-2025-05-14"]);
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
it("omits display for GovCloud regions on adaptive Claude thinking", async () => {
|
| 150 |
+
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
| 151 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 152 |
+
...baseModel,
|
| 153 |
+
id: "global.anthropic.claude-opus-4-8-v1",
|
| 154 |
+
name: "Claude Opus 4.8 (Global)",
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
const payload = await capturePayload(model, { region: "us-gov-west-1" });
|
| 158 |
+
|
| 159 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive" });
|
| 160 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
| 161 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toBeUndefined();
|
| 162 |
+
});
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
describe.skipIf(!hasBedrockCredentials())("Bedrock Claude max tokens E2E", () => {
|
| 166 |
+
it(
|
| 167 |
+
"uses the model maxTokens cap instead of Bedrock's 4096-token default for adaptive Claude models",
|
| 168 |
+
{ retry: 2, timeout: 180000 },
|
| 169 |
+
async () => {
|
| 170 |
+
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-6");
|
| 171 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 172 |
+
...baseModel,
|
| 173 |
+
maxTokens: 6000,
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
const response = await streamBedrock(
|
| 177 |
+
model,
|
| 178 |
+
{
|
| 179 |
+
systemPrompt: "You are a deterministic text generator. Follow the requested output format exactly.",
|
| 180 |
+
messages: [
|
| 181 |
+
{
|
| 182 |
+
role: "user",
|
| 183 |
+
content:
|
| 184 |
+
"Output exactly 5200 repetitions of the token alpha, separated by single spaces. Do not number them. Do not use markdown. Do not add any other text.",
|
| 185 |
+
timestamp: Date.now(),
|
| 186 |
+
},
|
| 187 |
+
],
|
| 188 |
+
},
|
| 189 |
+
{ reasoning: "low" },
|
| 190 |
+
).result();
|
| 191 |
+
|
| 192 |
+
expect(response.stopReason, response.errorMessage).not.toBe("error");
|
| 193 |
+
expect(response.usage.output).toBeGreaterThan(4096);
|
| 194 |
+
},
|
| 195 |
+
);
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
describe("Application inference profile support", () => {
|
| 199 |
+
it("uses adaptive thinking when model.name contains the model name but ARN does not", async () => {
|
| 200 |
+
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
| 201 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 202 |
+
...baseModel,
|
| 203 |
+
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
|
| 204 |
+
name: "Claude Opus 4.6",
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
const payload = await capturePayload(model);
|
| 208 |
+
|
| 209 |
+
expect(payload.additionalModelRequestFields?.thinking).toEqual({ type: "adaptive", display: "summarized" });
|
| 210 |
+
expect(payload.additionalModelRequestFields?.output_config).toEqual({ effort: "high" });
|
| 211 |
+
});
|
| 212 |
+
|
| 213 |
+
it("injects cache points when model.name identifies a supported Claude model", async () => {
|
| 214 |
+
const baseModel = getModel("amazon-bedrock", "global.anthropic.claude-opus-4-6-v1");
|
| 215 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 216 |
+
...baseModel,
|
| 217 |
+
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
|
| 218 |
+
name: "Claude Sonnet 4.6",
|
| 219 |
+
};
|
| 220 |
+
|
| 221 |
+
let capturedPayload: any;
|
| 222 |
+
const s = streamBedrock(
|
| 223 |
+
model,
|
| 224 |
+
{
|
| 225 |
+
systemPrompt: "You are helpful.",
|
| 226 |
+
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
onPayload: (payload) => {
|
| 230 |
+
capturedPayload = payload;
|
| 231 |
+
throw new PayloadCaptured();
|
| 232 |
+
},
|
| 233 |
+
},
|
| 234 |
+
);
|
| 235 |
+
|
| 236 |
+
for await (const event of s) {
|
| 237 |
+
if (event.type === "error") break;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
// System prompt should have a cache point
|
| 241 |
+
expect(capturedPayload.system).toHaveLength(2);
|
| 242 |
+
expect(capturedPayload.system[1]).toHaveProperty("cachePoint");
|
| 243 |
+
|
| 244 |
+
// Last user message should have a cache point
|
| 245 |
+
const lastMsg = capturedPayload.messages[capturedPayload.messages.length - 1];
|
| 246 |
+
const lastContent = lastMsg.content[lastMsg.content.length - 1];
|
| 247 |
+
expect(lastContent).toHaveProperty("cachePoint");
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
it("falls back to fixed-budget thinking for non-adaptive Claude via model.name", async () => {
|
| 251 |
+
const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 252 |
+
const model: Model<"bedrock-converse-stream"> = {
|
| 253 |
+
...baseModel,
|
| 254 |
+
id: "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/my-profile",
|
| 255 |
+
name: "Claude Sonnet 4.5",
|
| 256 |
+
};
|
| 257 |
+
|
| 258 |
+
const payload = await capturePayload(model);
|
| 259 |
+
|
| 260 |
+
expect(payload.additionalModelRequestFields?.thinking).toMatchObject({
|
| 261 |
+
type: "enabled",
|
| 262 |
+
budget_tokens: expect.any(Number),
|
| 263 |
+
});
|
| 264 |
+
expect(payload.additionalModelRequestFields?.anthropic_beta).toEqual(["interleaved-thinking-2025-05-14"]);
|
| 265 |
+
});
|
| 266 |
+
});
|
packages/ai/test/cloudflare-utils.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function hasCloudflareWorkersAICredentials(): boolean {
|
| 2 |
+
return !!process.env.CLOUDFLARE_API_KEY && !!process.env.CLOUDFLARE_ACCOUNT_ID;
|
| 3 |
+
}
|
| 4 |
+
|
| 5 |
+
export function hasCloudflareAiGatewayCredentials(): boolean {
|
| 6 |
+
return (
|
| 7 |
+
!!process.env.CLOUDFLARE_API_KEY && !!process.env.CLOUDFLARE_ACCOUNT_ID && !!process.env.CLOUDFLARE_GATEWAY_ID
|
| 8 |
+
);
|
| 9 |
+
}
|
packages/ai/test/compat-env.test.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { afterEach, describe, expect, it } from "vitest";
|
| 2 |
+
import { complete, registerApiProvider, resetApiProviders } from "../src/compat.ts";
|
| 3 |
+
import type { AssistantMessage, Context, Model } from "../src/types.ts";
|
| 4 |
+
import { AssistantMessageEventStream } from "../src/utils/event-stream.ts";
|
| 5 |
+
|
| 6 |
+
const context: Context = { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] };
|
| 7 |
+
|
| 8 |
+
const model: Model<"openai-responses"> = {
|
| 9 |
+
id: "test-model",
|
| 10 |
+
name: "Test Model",
|
| 11 |
+
api: "openai-responses",
|
| 12 |
+
provider: "custom-openai",
|
| 13 |
+
baseUrl: "https://example.test/v1",
|
| 14 |
+
reasoning: false,
|
| 15 |
+
input: ["text"],
|
| 16 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 17 |
+
contextWindow: 128000,
|
| 18 |
+
maxTokens: 4096,
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
function message(): AssistantMessage {
|
| 22 |
+
return {
|
| 23 |
+
role: "assistant",
|
| 24 |
+
content: [{ type: "text", text: "ok" }],
|
| 25 |
+
api: model.api,
|
| 26 |
+
provider: model.provider,
|
| 27 |
+
model: model.id,
|
| 28 |
+
usage: {
|
| 29 |
+
input: 0,
|
| 30 |
+
output: 0,
|
| 31 |
+
cacheRead: 0,
|
| 32 |
+
cacheWrite: 0,
|
| 33 |
+
totalTokens: 0,
|
| 34 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 35 |
+
},
|
| 36 |
+
stopReason: "stop",
|
| 37 |
+
timestamp: Date.now(),
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
describe("compat legacy API fallback", () => {
|
| 42 |
+
afterEach(() => {
|
| 43 |
+
resetApiProviders();
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
it("dispatches unknown providers through the legacy API registry", async () => {
|
| 47 |
+
let capturedApiKey: string | undefined;
|
| 48 |
+
registerApiProvider({
|
| 49 |
+
api: "openai-responses",
|
| 50 |
+
stream: (_model, _context, options) => {
|
| 51 |
+
capturedApiKey = options?.apiKey;
|
| 52 |
+
const stream = new AssistantMessageEventStream();
|
| 53 |
+
const output = message();
|
| 54 |
+
stream.push({ type: "start", partial: output });
|
| 55 |
+
stream.push({ type: "done", reason: "stop", message: output });
|
| 56 |
+
stream.end(output);
|
| 57 |
+
return stream;
|
| 58 |
+
},
|
| 59 |
+
streamSimple: (_model, _context, options) => {
|
| 60 |
+
capturedApiKey = options?.apiKey;
|
| 61 |
+
const stream = new AssistantMessageEventStream();
|
| 62 |
+
const output = message();
|
| 63 |
+
stream.push({ type: "start", partial: output });
|
| 64 |
+
stream.push({ type: "done", reason: "stop", message: output });
|
| 65 |
+
stream.end(output);
|
| 66 |
+
return stream;
|
| 67 |
+
},
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
await complete(model, context, { apiKey: "request-key" });
|
| 71 |
+
|
| 72 |
+
expect(capturedApiKey).toBe("request-key");
|
| 73 |
+
});
|
| 74 |
+
});
|
packages/ai/test/cross-provider-handoff.test.ts
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Cross-Provider Handoff Test
|
| 3 |
+
*
|
| 4 |
+
* Tests that contexts generated by one provider/model can be consumed by another.
|
| 5 |
+
* This catches issues like:
|
| 6 |
+
* - Tool call ID format incompatibilities (e.g., OpenAI Codex pipe characters)
|
| 7 |
+
* - Thinking block transformation issues
|
| 8 |
+
* - Message format incompatibilities
|
| 9 |
+
*
|
| 10 |
+
* Strategy:
|
| 11 |
+
* 1. beforeAll: For each provider/model, generate a "small context" (if not cached):
|
| 12 |
+
* - User message asking to use a tool
|
| 13 |
+
* - Assistant response with thinking + tool call
|
| 14 |
+
* - Tool result
|
| 15 |
+
* - Final assistant response
|
| 16 |
+
*
|
| 17 |
+
* 2. Test: For each target provider/model:
|
| 18 |
+
* - Concatenate ALL other contexts into one
|
| 19 |
+
* - Ask the model to "say hi"
|
| 20 |
+
* - If it fails, there's a compatibility issue
|
| 21 |
+
*
|
| 22 |
+
* Fixtures are generated fresh on each run.
|
| 23 |
+
*/
|
| 24 |
+
|
| 25 |
+
import { writeFileSync } from "fs";
|
| 26 |
+
import { Type } from "typebox";
|
| 27 |
+
import { beforeAll, describe, expect, it } from "vitest";
|
| 28 |
+
import { completeSimple, getEnvApiKey, getModel } from "../src/compat.ts";
|
| 29 |
+
import type { Api, AssistantMessage, Message, Model, Tool, ToolResultMessage } from "../src/types.ts";
|
| 30 |
+
import { hasAzureOpenAICredentials } from "./azure-utils.ts";
|
| 31 |
+
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";
|
| 32 |
+
import { resolveApiKey } from "./oauth.ts";
|
| 33 |
+
|
| 34 |
+
// Simple tool for testing
|
| 35 |
+
const testToolSchema = Type.Object({
|
| 36 |
+
value: Type.Number({ description: "A number to double" }),
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
const testTool: Tool<typeof testToolSchema> = {
|
| 40 |
+
name: "double_number",
|
| 41 |
+
description: "Doubles a number and returns the result",
|
| 42 |
+
parameters: testToolSchema,
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
// Provider/model pairs to test
|
| 46 |
+
interface ProviderModelPair {
|
| 47 |
+
provider: string;
|
| 48 |
+
model: string;
|
| 49 |
+
label: string;
|
| 50 |
+
apiOverride?: Api;
|
| 51 |
+
upstreamApiKeyEnv?: string;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [
|
| 55 |
+
// Anthropic
|
| 56 |
+
{ provider: "anthropic", model: "claude-sonnet-4-5", label: "anthropic-claude-sonnet-4-5" },
|
| 57 |
+
// Google
|
| 58 |
+
{ provider: "google", model: "gemini-3-flash-preview", label: "google-gemini-3-flash-preview" },
|
| 59 |
+
// OpenAI
|
| 60 |
+
{
|
| 61 |
+
provider: "openai",
|
| 62 |
+
model: "gpt-4o-mini",
|
| 63 |
+
label: "openai-completions-gpt-4o-mini",
|
| 64 |
+
apiOverride: "openai-completions",
|
| 65 |
+
},
|
| 66 |
+
{ provider: "openai", model: "gpt-5-mini", label: "openai-responses-gpt-5-mini" },
|
| 67 |
+
{ provider: "azure-openai-responses", model: "gpt-4o-mini", label: "azure-openai-responses-gpt-4o-mini" },
|
| 68 |
+
// OpenAI Codex
|
| 69 |
+
{ provider: "openai-codex", model: "gpt-5.5", label: "openai-codex-gpt-5.5" },
|
| 70 |
+
// GitHub Copilot
|
| 71 |
+
{ provider: "github-copilot", model: "claude-sonnet-4.5", label: "copilot-claude-sonnet-4.5" },
|
| 72 |
+
{ provider: "github-copilot", model: "gpt-5.1-codex", label: "copilot-gpt-5.1-codex" },
|
| 73 |
+
{ provider: "github-copilot", model: "gemini-3-flash-preview", label: "copilot-gemini-3-flash-preview" },
|
| 74 |
+
{ provider: "github-copilot", model: "grok-code-fast-1", label: "copilot-grok-code-fast-1" },
|
| 75 |
+
// Amazon Bedrock
|
| 76 |
+
{
|
| 77 |
+
provider: "amazon-bedrock",
|
| 78 |
+
model: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
| 79 |
+
label: "bedrock-claude-sonnet-4-5",
|
| 80 |
+
},
|
| 81 |
+
// xAI
|
| 82 |
+
{ provider: "xai", model: "grok-4.3", label: "xai-grok-4.3" },
|
| 83 |
+
// Cerebras
|
| 84 |
+
{ provider: "cerebras", model: "zai-glm-4.7", label: "cerebras-zai-glm-4.7" },
|
| 85 |
+
// Cloudflare Workers AI
|
| 86 |
+
{ provider: "cloudflare-workers-ai", model: "@cf/moonshotai/kimi-k2.6", label: "cloudflare-kimi-k2.6" },
|
| 87 |
+
// Cloudflare AI Gateway
|
| 88 |
+
{
|
| 89 |
+
provider: "cloudflare-ai-gateway",
|
| 90 |
+
model: "workers-ai/@cf/moonshotai/kimi-k2.6",
|
| 91 |
+
label: "cloudflare-gateway-kimi-k2.6",
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
provider: "cloudflare-ai-gateway",
|
| 95 |
+
model: "claude-sonnet-4-5",
|
| 96 |
+
label: "cloudflare-gateway-claude-sonnet-4-5",
|
| 97 |
+
upstreamApiKeyEnv: "ANTHROPIC_API_KEY",
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
provider: "cloudflare-ai-gateway",
|
| 101 |
+
model: "gpt-5.1",
|
| 102 |
+
label: "cloudflare-gateway-gpt-5.1",
|
| 103 |
+
upstreamApiKeyEnv: "OPENAI_API_KEY",
|
| 104 |
+
},
|
| 105 |
+
// Groq
|
| 106 |
+
{ provider: "groq", model: "openai/gpt-oss-120b", label: "groq-gpt-oss-120b" },
|
| 107 |
+
// Hugging Face
|
| 108 |
+
{ provider: "huggingface", model: "moonshotai/Kimi-K2.5", label: "huggingface-kimi-k2.5" },
|
| 109 |
+
// Together AI
|
| 110 |
+
{ provider: "together", model: "moonshotai/Kimi-K2.6", label: "together-kimi-k2.6" },
|
| 111 |
+
// Baseten
|
| 112 |
+
{ provider: "baseten", model: "zai-org/GLM-5.2", label: "baseten-glm-5.2" },
|
| 113 |
+
// Kimi For Coding
|
| 114 |
+
{ provider: "kimi-coding", model: "kimi-for-coding", label: "kimi-for-coding" },
|
| 115 |
+
// Mistral
|
| 116 |
+
{ provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" },
|
| 117 |
+
// MiniMax
|
| 118 |
+
{ provider: "minimax", model: "MiniMax-M2.7", label: "minimax-m2.7" },
|
| 119 |
+
{ provider: "minimax-cn", model: "MiniMax-M2.7", label: "minimax-m2.7" },
|
| 120 |
+
// OpenCode Zen
|
| 121 |
+
{ provider: "opencode", model: "big-pickle", label: "zen-big-pickle" },
|
| 122 |
+
{ provider: "opencode", model: "claude-sonnet-4-5", label: "zen-claude-sonnet-4-5" },
|
| 123 |
+
{ provider: "opencode", model: "gemini-3-flash", label: "zen-gemini-3-flash" },
|
| 124 |
+
{ provider: "opencode", model: "glm-4.7-free", label: "zen-glm-4.7-free" },
|
| 125 |
+
{ provider: "opencode", model: "gpt-5.2-codex", label: "zen-gpt-5.2-codex" },
|
| 126 |
+
{ provider: "opencode", model: "minimax-m2.1-free", label: "zen-minimax-m2.1-free" },
|
| 127 |
+
// OpenCode Go
|
| 128 |
+
{ provider: "opencode-go", model: "kimi-k2.5", label: "go-kimi-k2.5" },
|
| 129 |
+
{ provider: "opencode-go", model: "minimax-m2.5", label: "go-minimax-m2.5" },
|
| 130 |
+
// Xiaomi MiMo
|
| 131 |
+
{ provider: "xiaomi", model: "mimo-v2.5-pro", label: "xiaomi-mimo-v2.5-pro" },
|
| 132 |
+
{ provider: "xiaomi-token-plan-cn", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-cn-mimo-v2.5-pro" },
|
| 133 |
+
{ provider: "xiaomi-token-plan-ams", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-ams-mimo-v2.5-pro" },
|
| 134 |
+
{ provider: "xiaomi-token-plan-sgp", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-sgp-mimo-v2.5-pro" },
|
| 135 |
+
// Qwen Token Plan
|
| 136 |
+
{ provider: "qwen-token-plan", model: "qwen3.7-max", label: "qwen-token-plan-qwen3.7-max" },
|
| 137 |
+
{ provider: "qwen-token-plan-cn", model: "qwen3.7-max", label: "qwen-token-plan-cn-qwen3.7-max" },
|
| 138 |
+
{
|
| 139 |
+
provider: "qwen-token-plan-individual",
|
| 140 |
+
model: "qwen3.8-max",
|
| 141 |
+
label: "qwen-token-plan-individual-qwen3.8-max",
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
provider: "qwen-token-plan-individual",
|
| 145 |
+
model: "deepseek-v4-flash-0731",
|
| 146 |
+
label: "qwen-token-plan-individual-deepseek-v4-flash-0731",
|
| 147 |
+
},
|
| 148 |
+
{
|
| 149 |
+
provider: "qwen-token-plan-individual",
|
| 150 |
+
model: "glm-5.2",
|
| 151 |
+
label: "qwen-token-plan-individual-glm-5.2",
|
| 152 |
+
},
|
| 153 |
+
];
|
| 154 |
+
|
| 155 |
+
// Cached context structure
|
| 156 |
+
interface CachedContext {
|
| 157 |
+
label: string;
|
| 158 |
+
provider: string;
|
| 159 |
+
model: string;
|
| 160 |
+
api: Api;
|
| 161 |
+
messages: Message[];
|
| 162 |
+
generatedAt: string;
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/**
|
| 166 |
+
* Get API key for provider - checks OAuth storage first, then env vars
|
| 167 |
+
*/
|
| 168 |
+
async function getApiKey(provider: string): Promise<string | undefined> {
|
| 169 |
+
const oauthKey = await resolveApiKey(provider);
|
| 170 |
+
if (oauthKey) return oauthKey;
|
| 171 |
+
return getEnvApiKey(provider);
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* Synchronous check for API key availability (env vars only, for skipIf)
|
| 176 |
+
*/
|
| 177 |
+
function hasApiKey(pair: ProviderModelPair): boolean {
|
| 178 |
+
if (pair.provider === "azure-openai-responses") {
|
| 179 |
+
return hasAzureOpenAICredentials();
|
| 180 |
+
}
|
| 181 |
+
if (pair.provider === "cloudflare-workers-ai") {
|
| 182 |
+
return hasCloudflareWorkersAICredentials();
|
| 183 |
+
}
|
| 184 |
+
if (pair.provider === "cloudflare-ai-gateway") {
|
| 185 |
+
if (!hasCloudflareAiGatewayCredentials()) return false;
|
| 186 |
+
return pair.upstreamApiKeyEnv ? !!process.env[pair.upstreamApiKeyEnv] : true;
|
| 187 |
+
}
|
| 188 |
+
return !!getEnvApiKey(pair.provider);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function getHeaders(pair: ProviderModelPair): Record<string, string> | undefined {
|
| 192 |
+
if (!pair.upstreamApiKeyEnv) return undefined;
|
| 193 |
+
const upstreamApiKey = process.env[pair.upstreamApiKeyEnv];
|
| 194 |
+
return upstreamApiKey ? { Authorization: `Bearer ${upstreamApiKey}` } : undefined;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/**
|
| 198 |
+
* Check if any provider has API keys available (for skipIf at describe level)
|
| 199 |
+
*/
|
| 200 |
+
function hasAnyApiKey(): boolean {
|
| 201 |
+
return PROVIDER_MODEL_PAIRS.some((pair) => hasApiKey(pair));
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function dumpFailurePayload(params: { label: string; error: string; payload?: unknown; messages: Message[] }): void {
|
| 205 |
+
const filename = `/tmp/pi-handoff-${params.label}-${Date.now()}.json`;
|
| 206 |
+
const body = {
|
| 207 |
+
label: params.label,
|
| 208 |
+
error: params.error,
|
| 209 |
+
payload: params.payload,
|
| 210 |
+
messages: params.messages,
|
| 211 |
+
};
|
| 212 |
+
writeFileSync(filename, JSON.stringify(body, null, 2));
|
| 213 |
+
console.log(`Wrote failure payload to ${filename}`);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
/**
|
| 217 |
+
* Generate a context from a provider/model pair.
|
| 218 |
+
* Makes a real API call to get authentic tool call IDs and thinking blocks.
|
| 219 |
+
*/
|
| 220 |
+
async function generateContext(
|
| 221 |
+
pair: ProviderModelPair,
|
| 222 |
+
apiKey: string,
|
| 223 |
+
): Promise<{ messages: Message[]; api: Api } | null> {
|
| 224 |
+
const baseModel = (getModel as (p: string, m: string) => Model<Api> | undefined)(pair.provider, pair.model);
|
| 225 |
+
if (!baseModel) {
|
| 226 |
+
console.log(` Model not found: ${pair.provider}/${pair.model}`);
|
| 227 |
+
return null;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
const model: Model<Api> = pair.apiOverride ? { ...baseModel, api: pair.apiOverride } : baseModel;
|
| 231 |
+
|
| 232 |
+
const userMessage: Message = {
|
| 233 |
+
role: "user",
|
| 234 |
+
content: "Please double the number 21 using the double_number tool.",
|
| 235 |
+
timestamp: Date.now(),
|
| 236 |
+
};
|
| 237 |
+
|
| 238 |
+
const supportsReasoning = model.reasoning === true;
|
| 239 |
+
const headers = getHeaders(pair);
|
| 240 |
+
let lastPayload: unknown;
|
| 241 |
+
let assistantResponse: AssistantMessage;
|
| 242 |
+
try {
|
| 243 |
+
assistantResponse = await completeSimple(
|
| 244 |
+
model,
|
| 245 |
+
{
|
| 246 |
+
systemPrompt: "You are a helpful assistant. Use the provided tool to complete the task.",
|
| 247 |
+
messages: [userMessage],
|
| 248 |
+
tools: [testTool],
|
| 249 |
+
},
|
| 250 |
+
{
|
| 251 |
+
apiKey,
|
| 252 |
+
reasoning: supportsReasoning ? "high" : undefined,
|
| 253 |
+
headers,
|
| 254 |
+
onPayload: (payload) => {
|
| 255 |
+
lastPayload = payload;
|
| 256 |
+
},
|
| 257 |
+
},
|
| 258 |
+
);
|
| 259 |
+
} catch (error) {
|
| 260 |
+
const msg = error instanceof Error ? error.message : String(error);
|
| 261 |
+
console.log(` Initial request failed: ${msg}`);
|
| 262 |
+
dumpFailurePayload({
|
| 263 |
+
label: `${pair.label}-initial`,
|
| 264 |
+
error: msg,
|
| 265 |
+
payload: lastPayload,
|
| 266 |
+
messages: [userMessage],
|
| 267 |
+
});
|
| 268 |
+
return null;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if (assistantResponse.stopReason === "error") {
|
| 272 |
+
console.log(` Initial request error: ${assistantResponse.errorMessage}`);
|
| 273 |
+
dumpFailurePayload({
|
| 274 |
+
label: `${pair.label}-initial`,
|
| 275 |
+
error: assistantResponse.errorMessage || "Unknown error",
|
| 276 |
+
payload: lastPayload,
|
| 277 |
+
messages: [userMessage],
|
| 278 |
+
});
|
| 279 |
+
return null;
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
const toolCall = assistantResponse.content.find((c) => c.type === "toolCall");
|
| 283 |
+
if (!toolCall || toolCall.type !== "toolCall") {
|
| 284 |
+
console.log(` No tool call in response (stopReason: ${assistantResponse.stopReason})`);
|
| 285 |
+
return {
|
| 286 |
+
messages: [userMessage, assistantResponse],
|
| 287 |
+
api: model.api,
|
| 288 |
+
};
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
console.log(` Tool call ID: ${toolCall.id}`);
|
| 292 |
+
|
| 293 |
+
const toolResult: ToolResultMessage = {
|
| 294 |
+
role: "toolResult",
|
| 295 |
+
toolCallId: toolCall.id,
|
| 296 |
+
toolName: toolCall.name,
|
| 297 |
+
content: [{ type: "text", text: "42" }],
|
| 298 |
+
isError: false,
|
| 299 |
+
timestamp: Date.now(),
|
| 300 |
+
};
|
| 301 |
+
|
| 302 |
+
let finalResponse: AssistantMessage;
|
| 303 |
+
const messagesForFinal = [userMessage, assistantResponse, toolResult];
|
| 304 |
+
try {
|
| 305 |
+
finalResponse = await completeSimple(
|
| 306 |
+
model,
|
| 307 |
+
{
|
| 308 |
+
systemPrompt: "You are a helpful assistant.",
|
| 309 |
+
messages: messagesForFinal,
|
| 310 |
+
tools: [testTool],
|
| 311 |
+
},
|
| 312 |
+
{
|
| 313 |
+
apiKey,
|
| 314 |
+
reasoning: supportsReasoning ? "high" : undefined,
|
| 315 |
+
headers,
|
| 316 |
+
onPayload: (payload) => {
|
| 317 |
+
lastPayload = payload;
|
| 318 |
+
},
|
| 319 |
+
},
|
| 320 |
+
);
|
| 321 |
+
} catch (error) {
|
| 322 |
+
const msg = error instanceof Error ? error.message : String(error);
|
| 323 |
+
console.log(` Final request failed: ${msg}`);
|
| 324 |
+
dumpFailurePayload({
|
| 325 |
+
label: `${pair.label}-final`,
|
| 326 |
+
error: msg,
|
| 327 |
+
payload: lastPayload,
|
| 328 |
+
messages: messagesForFinal,
|
| 329 |
+
});
|
| 330 |
+
return null;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
if (finalResponse.stopReason === "error") {
|
| 334 |
+
console.log(` Final request error: ${finalResponse.errorMessage}`);
|
| 335 |
+
dumpFailurePayload({
|
| 336 |
+
label: `${pair.label}-final`,
|
| 337 |
+
error: finalResponse.errorMessage || "Unknown error",
|
| 338 |
+
payload: lastPayload,
|
| 339 |
+
messages: messagesForFinal,
|
| 340 |
+
});
|
| 341 |
+
return null;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
return {
|
| 345 |
+
messages: [userMessage, assistantResponse, toolResult, finalResponse],
|
| 346 |
+
api: model.api,
|
| 347 |
+
};
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => {
|
| 351 |
+
let contexts: Record<string, CachedContext>;
|
| 352 |
+
let availablePairs: ProviderModelPair[];
|
| 353 |
+
|
| 354 |
+
beforeAll(async () => {
|
| 355 |
+
contexts = {};
|
| 356 |
+
availablePairs = [];
|
| 357 |
+
|
| 358 |
+
console.log("\n=== Generating Fixtures ===\n");
|
| 359 |
+
|
| 360 |
+
for (const pair of PROVIDER_MODEL_PAIRS) {
|
| 361 |
+
const apiKey = await getApiKey(pair.provider);
|
| 362 |
+
if (!apiKey || !hasApiKey(pair)) {
|
| 363 |
+
console.log(`[${pair.label}] Skipping - no auth for ${pair.provider}`);
|
| 364 |
+
continue;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
console.log(`[${pair.label}] Generating fixture...`);
|
| 368 |
+
const result = await generateContext(pair, apiKey);
|
| 369 |
+
|
| 370 |
+
if (!result || result.messages.length < 4) {
|
| 371 |
+
console.log(`[${pair.label}] Failed to generate fixture, skipping`);
|
| 372 |
+
continue;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
contexts[pair.label] = {
|
| 376 |
+
label: pair.label,
|
| 377 |
+
provider: pair.provider,
|
| 378 |
+
model: pair.model,
|
| 379 |
+
api: result.api,
|
| 380 |
+
messages: result.messages,
|
| 381 |
+
generatedAt: new Date().toISOString(),
|
| 382 |
+
};
|
| 383 |
+
availablePairs.push(pair);
|
| 384 |
+
console.log(`[${pair.label}] Generated ${result.messages.length} messages`);
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
console.log(`\n=== ${availablePairs.length}/${PROVIDER_MODEL_PAIRS.length} contexts available ===\n`);
|
| 388 |
+
}, 300000);
|
| 389 |
+
|
| 390 |
+
it.skipIf(!hasAnyApiKey())("should have at least 2 fixtures to test handoffs", () => {
|
| 391 |
+
expect(Object.keys(contexts).length).toBeGreaterThanOrEqual(2);
|
| 392 |
+
});
|
| 393 |
+
|
| 394 |
+
it.skipIf(!hasAnyApiKey())(
|
| 395 |
+
"should handle cross-provider handoffs for each target",
|
| 396 |
+
async () => {
|
| 397 |
+
const contextLabels = Object.keys(contexts);
|
| 398 |
+
|
| 399 |
+
if (contextLabels.length < 2) {
|
| 400 |
+
console.log("Not enough fixtures for handoff test, skipping");
|
| 401 |
+
return;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
console.log("\n=== Testing Cross-Provider Handoffs ===\n");
|
| 405 |
+
|
| 406 |
+
const results: { target: string; success: boolean; error?: string }[] = [];
|
| 407 |
+
|
| 408 |
+
for (const targetPair of availablePairs) {
|
| 409 |
+
const apiKey = await getApiKey(targetPair.provider);
|
| 410 |
+
if (!apiKey || !hasApiKey(targetPair)) {
|
| 411 |
+
console.log(`[Target: ${targetPair.label}] Skipping - no auth`);
|
| 412 |
+
continue;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// Collect messages from ALL OTHER contexts
|
| 416 |
+
const otherMessages: Message[] = [];
|
| 417 |
+
for (const [label, ctx] of Object.entries(contexts)) {
|
| 418 |
+
if (label === targetPair.label) continue;
|
| 419 |
+
otherMessages.push(...ctx.messages);
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
if (otherMessages.length === 0) {
|
| 423 |
+
console.log(`[Target: ${targetPair.label}] Skipping - no other contexts`);
|
| 424 |
+
continue;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
const allMessages: Message[] = [
|
| 428 |
+
...otherMessages,
|
| 429 |
+
{
|
| 430 |
+
role: "user",
|
| 431 |
+
content:
|
| 432 |
+
"Great, thanks for all that help! Now just say 'Hello, handoff successful!' to confirm you received everything.",
|
| 433 |
+
timestamp: Date.now(),
|
| 434 |
+
},
|
| 435 |
+
];
|
| 436 |
+
|
| 437 |
+
const baseModel = (getModel as (p: string, m: string) => Model<Api> | undefined)(
|
| 438 |
+
targetPair.provider,
|
| 439 |
+
targetPair.model,
|
| 440 |
+
);
|
| 441 |
+
if (!baseModel) {
|
| 442 |
+
console.log(`[Target: ${targetPair.label}] Model not found`);
|
| 443 |
+
continue;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
const model: Model<Api> = targetPair.apiOverride
|
| 447 |
+
? { ...baseModel, api: targetPair.apiOverride }
|
| 448 |
+
: baseModel;
|
| 449 |
+
const supportsReasoning = model.reasoning === true;
|
| 450 |
+
const headers = getHeaders(targetPair);
|
| 451 |
+
|
| 452 |
+
console.log(
|
| 453 |
+
`[Target: ${targetPair.label}] Testing with ${otherMessages.length} messages from other providers...`,
|
| 454 |
+
);
|
| 455 |
+
|
| 456 |
+
let lastPayload: unknown;
|
| 457 |
+
try {
|
| 458 |
+
const response = await completeSimple(
|
| 459 |
+
model,
|
| 460 |
+
{
|
| 461 |
+
systemPrompt: "You are a helpful assistant.",
|
| 462 |
+
messages: allMessages,
|
| 463 |
+
tools: [testTool],
|
| 464 |
+
},
|
| 465 |
+
{
|
| 466 |
+
apiKey,
|
| 467 |
+
reasoning: supportsReasoning ? "high" : undefined,
|
| 468 |
+
headers,
|
| 469 |
+
onPayload: (payload) => {
|
| 470 |
+
lastPayload = payload;
|
| 471 |
+
},
|
| 472 |
+
},
|
| 473 |
+
);
|
| 474 |
+
|
| 475 |
+
if (response.stopReason === "error") {
|
| 476 |
+
console.log(`[Target: ${targetPair.label}] FAILED: ${response.errorMessage}`);
|
| 477 |
+
dumpFailurePayload({
|
| 478 |
+
label: targetPair.label,
|
| 479 |
+
error: response.errorMessage || "Unknown error",
|
| 480 |
+
payload: lastPayload,
|
| 481 |
+
messages: allMessages,
|
| 482 |
+
});
|
| 483 |
+
results.push({ target: targetPair.label, success: false, error: response.errorMessage });
|
| 484 |
+
} else {
|
| 485 |
+
const text = response.content
|
| 486 |
+
.filter((c) => c.type === "text")
|
| 487 |
+
.map((c) => c.text)
|
| 488 |
+
.join(" ");
|
| 489 |
+
const preview = text.slice(0, 100).replace(/\n/g, " ");
|
| 490 |
+
console.log(`[Target: ${targetPair.label}] SUCCESS: ${preview}...`);
|
| 491 |
+
results.push({ target: targetPair.label, success: true });
|
| 492 |
+
}
|
| 493 |
+
} catch (error) {
|
| 494 |
+
const msg = error instanceof Error ? error.message : String(error);
|
| 495 |
+
console.log(`[Target: ${targetPair.label}] EXCEPTION: ${msg}`);
|
| 496 |
+
dumpFailurePayload({
|
| 497 |
+
label: targetPair.label,
|
| 498 |
+
error: msg,
|
| 499 |
+
payload: lastPayload,
|
| 500 |
+
messages: allMessages,
|
| 501 |
+
});
|
| 502 |
+
results.push({ target: targetPair.label, success: false, error: msg });
|
| 503 |
+
}
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
console.log("\n=== Results Summary ===\n");
|
| 507 |
+
const successes = results.filter((r) => r.success);
|
| 508 |
+
const failures = results.filter((r) => !r.success);
|
| 509 |
+
|
| 510 |
+
console.log(`Passed: ${successes.length}/${results.length}`);
|
| 511 |
+
if (failures.length > 0) {
|
| 512 |
+
console.log("\nFailures:");
|
| 513 |
+
for (const f of failures) {
|
| 514 |
+
console.log(` - ${f.target}: ${f.error}`);
|
| 515 |
+
}
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
expect(failures.length).toBe(0);
|
| 519 |
+
},
|
| 520 |
+
600000,
|
| 521 |
+
);
|
| 522 |
+
});
|
packages/ai/test/deferred-tools.test.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Type } from "typebox";
|
| 2 |
+
import { describe, expect, it } from "vitest";
|
| 3 |
+
import { convertMessages } from "../src/api/openai-completions.ts";
|
| 4 |
+
import { getModel, streamSimple } from "../src/compat.ts";
|
| 5 |
+
import type { Api, AssistantMessage, Context, Model, Tool, ToolResultMessage, UserMessage } from "../src/types.ts";
|
| 6 |
+
import { estimateContextTokens } from "../src/utils/estimate.ts";
|
| 7 |
+
|
| 8 |
+
interface AnthropicToolPayload {
|
| 9 |
+
name: string;
|
| 10 |
+
description?: string;
|
| 11 |
+
defer_loading?: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface AnthropicContentBlock {
|
| 15 |
+
type: string;
|
| 16 |
+
text?: string;
|
| 17 |
+
tool_use_id?: string;
|
| 18 |
+
content?: string | Array<{ type: string; tool_name?: string }>;
|
| 19 |
+
source?: {
|
| 20 |
+
type: string;
|
| 21 |
+
media_type: string;
|
| 22 |
+
data: string;
|
| 23 |
+
};
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
interface AnthropicPayload {
|
| 27 |
+
tools?: AnthropicToolPayload[];
|
| 28 |
+
messages: Array<{
|
| 29 |
+
content: string | AnthropicContentBlock[];
|
| 30 |
+
}>;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface OpenAIToolSearchCall {
|
| 34 |
+
type: "tool_search_call";
|
| 35 |
+
call_id?: string | null;
|
| 36 |
+
execution?: string;
|
| 37 |
+
status?: string | null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
interface OpenAIToolSearchOutput {
|
| 41 |
+
type: "tool_search_output";
|
| 42 |
+
call_id?: string | null;
|
| 43 |
+
execution?: string;
|
| 44 |
+
status?: string | null;
|
| 45 |
+
tools: Array<{ type: string; name: string; defer_loading?: boolean }>;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
interface OpenAIAdditionalTools {
|
| 49 |
+
type: "additional_tools";
|
| 50 |
+
role: "developer";
|
| 51 |
+
tools: Array<{ type: string; name: string; defer_loading?: boolean }>;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
interface OpenAIPayload {
|
| 55 |
+
tools?: Array<{ name?: string; function?: { name: string } }>;
|
| 56 |
+
input?: Array<
|
| 57 |
+
OpenAIAdditionalTools | OpenAIToolSearchCall | OpenAIToolSearchOutput | { type?: string; name?: string }
|
| 58 |
+
>;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
interface KimiTool {
|
| 62 |
+
type: "function";
|
| 63 |
+
function: {
|
| 64 |
+
name: string;
|
| 65 |
+
description?: string;
|
| 66 |
+
parameters?: Record<string, unknown>;
|
| 67 |
+
};
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
interface KimiMessage {
|
| 71 |
+
role: string;
|
| 72 |
+
content?: unknown;
|
| 73 |
+
tools?: KimiTool[];
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
interface KimiPayload {
|
| 77 |
+
tools?: KimiTool[];
|
| 78 |
+
messages: KimiMessage[];
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
class PayloadCaptured extends Error {}
|
| 82 |
+
|
| 83 |
+
function makeTool(name: string): Tool {
|
| 84 |
+
return {
|
| 85 |
+
name,
|
| 86 |
+
description: `The ${name} tool`,
|
| 87 |
+
parameters: Type.Object({ value: Type.String() }),
|
| 88 |
+
};
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function makeUserMessage(timestamp: number): UserMessage {
|
| 92 |
+
return { role: "user", content: "Hello", timestamp };
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function makeAssistantToolCall(): AssistantMessage {
|
| 96 |
+
return {
|
| 97 |
+
role: "assistant",
|
| 98 |
+
content: [{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} }],
|
| 99 |
+
api: "anthropic-messages",
|
| 100 |
+
provider: "anthropic",
|
| 101 |
+
model: "claude-opus-4-6",
|
| 102 |
+
usage: {
|
| 103 |
+
input: 0,
|
| 104 |
+
output: 0,
|
| 105 |
+
cacheRead: 0,
|
| 106 |
+
cacheWrite: 0,
|
| 107 |
+
totalTokens: 0,
|
| 108 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 109 |
+
},
|
| 110 |
+
stopReason: "toolUse",
|
| 111 |
+
timestamp: 2,
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function makeToolResult(addedToolNames: string[]): ToolResultMessage {
|
| 116 |
+
return {
|
| 117 |
+
role: "toolResult",
|
| 118 |
+
toolCallId: "call_1",
|
| 119 |
+
toolName: "base_tool",
|
| 120 |
+
content: [{ type: "text", text: "done" }],
|
| 121 |
+
addedToolNames,
|
| 122 |
+
isError: false,
|
| 123 |
+
timestamp: 3,
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function makeContext(tools: Tool[], addedToolNames = ["late_tool"]): Context {
|
| 128 |
+
return {
|
| 129 |
+
messages: [makeUserMessage(1), makeAssistantToolCall(), makeToolResult(addedToolNames), makeUserMessage(4)],
|
| 130 |
+
tools,
|
| 131 |
+
};
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function makeKimiModel(deferredToolsMode?: "kimi"): Model<"openai-completions"> {
|
| 135 |
+
return {
|
| 136 |
+
id: "deferred-tools-model",
|
| 137 |
+
name: "Deferred Tools Model",
|
| 138 |
+
api: "openai-completions",
|
| 139 |
+
provider: "moonshotai",
|
| 140 |
+
baseUrl: "http://127.0.0.1:9/v1",
|
| 141 |
+
reasoning: false,
|
| 142 |
+
input: ["text"],
|
| 143 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
| 144 |
+
contextWindow: 128000,
|
| 145 |
+
maxTokens: 4096,
|
| 146 |
+
compat: deferredToolsMode ? { deferredToolsMode } : undefined,
|
| 147 |
+
};
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
async function capturePayload<T>(model: Model<Api>, context: Context, apiKey = "fake-key"): Promise<T> {
|
| 151 |
+
let captured: T | undefined;
|
| 152 |
+
const stream = streamSimple({ ...model, baseUrl: "http://127.0.0.1:9" }, context, {
|
| 153 |
+
apiKey,
|
| 154 |
+
onPayload: (payload) => {
|
| 155 |
+
captured = payload as T;
|
| 156 |
+
throw new PayloadCaptured();
|
| 157 |
+
},
|
| 158 |
+
});
|
| 159 |
+
await stream.result();
|
| 160 |
+
if (!captured) throw new Error("Expected payload capture");
|
| 161 |
+
return captured;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
function findAnthropicToolResultContent(payload: AnthropicPayload): AnthropicContentBlock[] {
|
| 165 |
+
for (const message of payload.messages) {
|
| 166 |
+
if (typeof message.content !== "string" && message.content.some((block) => block.type === "tool_result")) {
|
| 167 |
+
return message.content;
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
throw new Error("No tool result in payload");
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function findAnthropicToolResult(payload: AnthropicPayload): AnthropicContentBlock {
|
| 174 |
+
const result = findAnthropicToolResultContent(payload).find((block) => block.type === "tool_result");
|
| 175 |
+
if (!result) throw new Error("No tool result in payload");
|
| 176 |
+
return result;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function openAIToolNames(payload: OpenAIPayload): string[] {
|
| 180 |
+
return (payload.tools ?? []).map((tool) => tool.name ?? tool.function?.name ?? "");
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function makeCodexToken(): string {
|
| 184 |
+
return `header.${btoa(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "account" } }))}.signature`;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
describe("deferred tools", () => {
|
| 188 |
+
it("loads an Anthropic tool at its tool-result marker", async () => {
|
| 189 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 190 |
+
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
| 191 |
+
|
| 192 |
+
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]);
|
| 193 |
+
expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]);
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
it("preserves tool output as sibling content after emitting references", async () => {
|
| 197 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 198 |
+
const assistant = context.messages[1] as AssistantMessage;
|
| 199 |
+
assistant.content = [
|
| 200 |
+
{ type: "toolCall", id: "call_1", name: "base_tool", arguments: {} },
|
| 201 |
+
{ type: "toolCall", id: "call_2", name: "base_tool", arguments: {} },
|
| 202 |
+
];
|
| 203 |
+
const firstResult = context.messages[2] as ToolResultMessage;
|
| 204 |
+
firstResult.content = [
|
| 205 |
+
{ type: "text", text: "work completed" },
|
| 206 |
+
{ type: "image", mimeType: "image/png", data: "aW1hZ2U=" },
|
| 207 |
+
];
|
| 208 |
+
context.messages.splice(3, 0, {
|
| 209 |
+
...makeToolResult([]),
|
| 210 |
+
toolCallId: "call_2",
|
| 211 |
+
content: [{ type: "text", text: "second result" }],
|
| 212 |
+
});
|
| 213 |
+
|
| 214 |
+
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
| 215 |
+
|
| 216 |
+
expect(findAnthropicToolResultContent(payload)).toMatchObject([
|
| 217 |
+
{
|
| 218 |
+
type: "tool_result",
|
| 219 |
+
tool_use_id: "call_1",
|
| 220 |
+
content: [{ type: "tool_reference", tool_name: "late_tool" }],
|
| 221 |
+
},
|
| 222 |
+
{ type: "tool_result", tool_use_id: "call_2", content: "second result" },
|
| 223 |
+
{ type: "text", text: "work completed" },
|
| 224 |
+
{
|
| 225 |
+
type: "image",
|
| 226 |
+
source: { type: "base64", media_type: "image/png", data: "aW1hZ2U=" },
|
| 227 |
+
},
|
| 228 |
+
]);
|
| 229 |
+
});
|
| 230 |
+
|
| 231 |
+
it("loads a tool introduced by OpenAI history after switching to Anthropic", async () => {
|
| 232 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 233 |
+
const assistant = context.messages[1] as AssistantMessage;
|
| 234 |
+
assistant.api = "openai-responses";
|
| 235 |
+
assistant.provider = "openai";
|
| 236 |
+
assistant.model = "gpt-5.4";
|
| 237 |
+
|
| 238 |
+
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-8"), context);
|
| 239 |
+
|
| 240 |
+
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "late_tool", defer_loading: true }]);
|
| 241 |
+
expect(findAnthropicToolResult(payload).content).toEqual([{ type: "tool_reference", tool_name: "late_tool" }]);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it("does not resurrect a marked tool missing from Context.tools", async () => {
|
| 245 |
+
const context = makeContext([makeTool("base_tool")]);
|
| 246 |
+
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
| 247 |
+
|
| 248 |
+
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool"]);
|
| 249 |
+
const content = findAnthropicToolResult(payload).content;
|
| 250 |
+
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
it("keeps a tool immediate when it was used before its marker", async () => {
|
| 254 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 255 |
+
const assistant = context.messages[1] as AssistantMessage;
|
| 256 |
+
assistant.content = [{ type: "toolCall", id: "call_1", name: "late_tool", arguments: {} }];
|
| 257 |
+
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
| 258 |
+
|
| 259 |
+
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]);
|
| 260 |
+
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
it("normalizes OAuth names before checking prior tool usage", async () => {
|
| 264 |
+
const context = makeContext([makeTool("base_tool"), makeTool("read")], ["read"]);
|
| 265 |
+
const assistant = context.messages[1] as AssistantMessage;
|
| 266 |
+
assistant.content = [{ type: "toolCall", id: "call_1", name: "Read", arguments: {} }];
|
| 267 |
+
const payload = await capturePayload<AnthropicPayload>(
|
| 268 |
+
getModel("anthropic", "claude-opus-4-6"),
|
| 269 |
+
context,
|
| 270 |
+
"sk-ant-oat-fake",
|
| 271 |
+
);
|
| 272 |
+
|
| 273 |
+
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "Read"]);
|
| 274 |
+
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
|
| 275 |
+
const content = findAnthropicToolResult(payload).content;
|
| 276 |
+
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
it("matches OAuth-canonicalized markers to active tools", async () => {
|
| 280 |
+
const context = makeContext([makeTool("base_tool"), makeTool("read")], ["Read"]);
|
| 281 |
+
const payload = await capturePayload<AnthropicPayload>(
|
| 282 |
+
getModel("anthropic", "claude-opus-4-6"),
|
| 283 |
+
context,
|
| 284 |
+
"sk-ant-oat-fake",
|
| 285 |
+
);
|
| 286 |
+
|
| 287 |
+
expect(payload.tools).toMatchObject([{ name: "base_tool" }, { name: "Read", defer_loading: true }]);
|
| 288 |
+
const content = findAnthropicToolResult(payload).content;
|
| 289 |
+
expect(
|
| 290 |
+
Array.isArray(content) &&
|
| 291 |
+
content.some((block) => block.type === "tool_reference" && block.tool_name === "Read"),
|
| 292 |
+
).toBe(true);
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
it("deduplicates active tools after OAuth canonicalization", async () => {
|
| 296 |
+
const context: Context = {
|
| 297 |
+
messages: [makeUserMessage(1)],
|
| 298 |
+
tools: [makeTool("read"), { ...makeTool("Read"), description: "Canonical definition" }],
|
| 299 |
+
};
|
| 300 |
+
const payload = await capturePayload<AnthropicPayload>(
|
| 301 |
+
getModel("anthropic", "claude-opus-4-6"),
|
| 302 |
+
context,
|
| 303 |
+
"sk-ant-oat-fake",
|
| 304 |
+
);
|
| 305 |
+
|
| 306 |
+
expect(payload.tools).toMatchObject([{ name: "Read", description: "Canonical definition" }]);
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
it("uses the normal tool list when Anthropic tool references are unsupported", async () => {
|
| 310 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 311 |
+
const models: Model<"anthropic-messages">[] = [
|
| 312 |
+
getModel("anthropic", "claude-haiku-4-5"),
|
| 313 |
+
{ ...getModel("anthropic", "claude-opus-4-6"), id: "claude-sonnet-4-20250514" },
|
| 314 |
+
];
|
| 315 |
+
|
| 316 |
+
for (const model of models) {
|
| 317 |
+
const payload = await capturePayload<AnthropicPayload>(model, context);
|
| 318 |
+
expect(payload.tools?.map((tool) => tool.name)).toEqual(["base_tool", "late_tool"]);
|
| 319 |
+
expect(payload.tools?.every((tool) => !tool.defer_loading)).toBe(true);
|
| 320 |
+
}
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
it("keeps one immediate Anthropic tool when every current tool is marked", async () => {
|
| 324 |
+
const context = makeContext([makeTool("late_tool")]);
|
| 325 |
+
const payload = await capturePayload<AnthropicPayload>(getModel("anthropic", "claude-opus-4-6"), context);
|
| 326 |
+
|
| 327 |
+
expect(payload.tools).toMatchObject([{ name: "late_tool" }]);
|
| 328 |
+
expect(payload.tools?.[0]?.defer_loading).toBeUndefined();
|
| 329 |
+
const content = findAnthropicToolResult(payload).content;
|
| 330 |
+
expect(Array.isArray(content) && content.some((block) => block.type === "tool_reference")).toBe(false);
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
it("supports explicit Anthropic compatibility overrides", async () => {
|
| 334 |
+
const model: Model<"anthropic-messages"> = {
|
| 335 |
+
...getModel("anthropic", "claude-opus-4-6"),
|
| 336 |
+
provider: "anthropic-proxy",
|
| 337 |
+
compat: { supportsToolReferences: true },
|
| 338 |
+
};
|
| 339 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 340 |
+
const payload = await capturePayload<AnthropicPayload>(model, context);
|
| 341 |
+
|
| 342 |
+
expect(payload.tools?.find((tool) => tool.name === "late_tool")?.defer_loading).toBe(true);
|
| 343 |
+
});
|
| 344 |
+
|
| 345 |
+
it("serializes Kimi deferred tools as system tool definitions", async () => {
|
| 346 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 347 |
+
const payload = await capturePayload<KimiPayload>(makeKimiModel("kimi"), context);
|
| 348 |
+
|
| 349 |
+
expect(payload.tools?.map((tool) => tool.function.name)).toEqual(["base_tool"]);
|
| 350 |
+
const toolResultIndex = payload.messages.findIndex((message) => message.role === "tool");
|
| 351 |
+
const systemToolIndex = payload.messages.findIndex((message) => message.tools !== undefined);
|
| 352 |
+
expect(toolResultIndex).toBeGreaterThanOrEqual(0);
|
| 353 |
+
expect(systemToolIndex).toBeGreaterThan(toolResultIndex);
|
| 354 |
+
expect(payload.messages[systemToolIndex]?.tools?.map((tool) => tool.function.name)).toEqual(["late_tool"]);
|
| 355 |
+
});
|
| 356 |
+
|
| 357 |
+
it("emits Kimi deferred schemas after all tool results in a batch", () => {
|
| 358 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool"), makeTool("later_tool")]);
|
| 359 |
+
context.messages.splice(3, 0, {
|
| 360 |
+
...makeToolResult(["later_tool"]),
|
| 361 |
+
toolCallId: "call_2",
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
const messages = convertMessages(makeKimiModel("kimi"), context, {
|
| 365 |
+
supportsStore: false,
|
| 366 |
+
supportsDeveloperRole: false,
|
| 367 |
+
supportsReasoningEffort: false,
|
| 368 |
+
supportsUsageInStreaming: true,
|
| 369 |
+
supportsFinishReason: true,
|
| 370 |
+
maxTokensField: "max_tokens",
|
| 371 |
+
requiresToolResultName: false,
|
| 372 |
+
requiresAssistantAfterToolResult: false,
|
| 373 |
+
requiresThinkingAsText: false,
|
| 374 |
+
requiresReasoningContentOnAssistantMessages: false,
|
| 375 |
+
thinkingFormat: "openai",
|
| 376 |
+
openRouterRouting: {},
|
| 377 |
+
vercelGatewayRouting: {},
|
| 378 |
+
chatTemplateKwargs: {},
|
| 379 |
+
chatTemplateArgs: {},
|
| 380 |
+
zaiToolStream: false,
|
| 381 |
+
supportsStrictMode: false,
|
| 382 |
+
supportsOpenAIGrammarTools: false,
|
| 383 |
+
cacheControlFormat: undefined,
|
| 384 |
+
sendSessionAffinityHeaders: false,
|
| 385 |
+
deferredToolsMode: "kimi",
|
| 386 |
+
sessionAffinityFormat: "openai",
|
| 387 |
+
supportsLongCacheRetention: false,
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "tool", "tool", "system", "user"]);
|
| 391 |
+
expect((messages[4] as { tools?: KimiTool[] }).tools?.map((tool) => tool.function.name)).toEqual([
|
| 392 |
+
"late_tool",
|
| 393 |
+
"later_tool",
|
| 394 |
+
]);
|
| 395 |
+
});
|
| 396 |
+
|
| 397 |
+
it("leaves OpenAI Completions tools unchanged without Kimi mode", async () => {
|
| 398 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 399 |
+
const payload = await capturePayload<KimiPayload>(makeKimiModel(), context);
|
| 400 |
+
|
| 401 |
+
expect(payload.tools?.map((tool) => tool.function.name)).toEqual(["base_tool", "late_tool"]);
|
| 402 |
+
expect(payload.messages.some((message) => message.tools !== undefined)).toBe(false);
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
it("loads an OpenAI Responses tool through additional_tools", async () => {
|
| 406 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 407 |
+
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), context);
|
| 408 |
+
const additionalTools = payload.input?.find(
|
| 409 |
+
(item): item is OpenAIAdditionalTools => item.type === "additional_tools",
|
| 410 |
+
);
|
| 411 |
+
|
| 412 |
+
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
|
| 413 |
+
expect(additionalTools).toMatchObject({ role: "developer" });
|
| 414 |
+
expect(additionalTools?.tools).toMatchObject([{ type: "function", name: "late_tool" }]);
|
| 415 |
+
expect(additionalTools?.tools.every((tool) => tool.defer_loading === undefined)).toBe(true);
|
| 416 |
+
expect(payload.input?.some((item) => item.type === "tool_search_call")).toBe(false);
|
| 417 |
+
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
it("preserves an additional_tools marker after the loaded tool is used", async () => {
|
| 421 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 422 |
+
const lateCall: AssistantMessage = {
|
| 423 |
+
...makeAssistantToolCall(),
|
| 424 |
+
content: [{ type: "toolCall", id: "call_late|fc_late", name: "late_tool", arguments: {} }],
|
| 425 |
+
api: "openai-responses",
|
| 426 |
+
provider: "openai",
|
| 427 |
+
model: "gpt-5.4",
|
| 428 |
+
};
|
| 429 |
+
context.messages.splice(3, 0, lateCall, {
|
| 430 |
+
...makeToolResult(["late_tool"]),
|
| 431 |
+
toolCallId: "call_late|fc_late",
|
| 432 |
+
toolName: "late_tool",
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
const payload = await capturePayload<OpenAIPayload>(getModel("openai", "gpt-5.4"), context);
|
| 436 |
+
const additionalToolIndexes = (payload.input ?? []).flatMap((item, index) =>
|
| 437 |
+
item.type === "additional_tools" ? [index] : [],
|
| 438 |
+
);
|
| 439 |
+
const lateCallIndex = (payload.input ?? []).findIndex(
|
| 440 |
+
(item) => item.type === "function_call" && item.name === "late_tool",
|
| 441 |
+
);
|
| 442 |
+
|
| 443 |
+
expect(additionalToolIndexes).toHaveLength(1);
|
| 444 |
+
expect(additionalToolIndexes[0]).toBeLessThan(lateCallIndex);
|
| 445 |
+
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
|
| 446 |
+
});
|
| 447 |
+
|
| 448 |
+
it("falls back to client tool search when additional_tools is unsupported", async () => {
|
| 449 |
+
const model: Model<"openai-responses"> = {
|
| 450 |
+
...getModel("openai", "gpt-5.4"),
|
| 451 |
+
provider: "openai-proxy",
|
| 452 |
+
compat: { supportsAdditionalTools: false, supportsToolSearch: true },
|
| 453 |
+
};
|
| 454 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 455 |
+
const payload = await capturePayload<OpenAIPayload>(model, context);
|
| 456 |
+
const searchCall = payload.input?.find((item): item is OpenAIToolSearchCall => item.type === "tool_search_call");
|
| 457 |
+
const searchOutput = payload.input?.find(
|
| 458 |
+
(item): item is OpenAIToolSearchOutput => item.type === "tool_search_output",
|
| 459 |
+
);
|
| 460 |
+
|
| 461 |
+
expect(openAIToolNames(payload)).toEqual(["base_tool"]);
|
| 462 |
+
expect(searchCall).toMatchObject({ execution: "client", status: "completed" });
|
| 463 |
+
expect(searchOutput?.call_id).toBe(searchCall?.call_id);
|
| 464 |
+
expect(searchOutput?.tools).toMatchObject([{ type: "function", name: "late_tool", defer_loading: true }]);
|
| 465 |
+
expect(payload.input?.some((item) => item.type === "additional_tools")).toBe(false);
|
| 466 |
+
});
|
| 467 |
+
|
| 468 |
+
it.each(["gpt-5.2", "gpt-5.4-nano", "gpt-5.5-pro"] as const)(
|
| 469 |
+
"uses the normal tool list for unsupported OpenAI model %s",
|
| 470 |
+
async (modelId) => {
|
| 471 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 472 |
+
const payload = await capturePayload<OpenAIPayload>(getModel("openai", modelId), context);
|
| 473 |
+
|
| 474 |
+
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
|
| 475 |
+
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
| 476 |
+
},
|
| 477 |
+
);
|
| 478 |
+
|
| 479 |
+
it("uses the normal tool list when OpenAI tool search is explicitly disabled", async () => {
|
| 480 |
+
const model: Model<"openai-responses"> = {
|
| 481 |
+
...getModel("openai", "gpt-5.4"),
|
| 482 |
+
provider: "openai-proxy",
|
| 483 |
+
compat: { supportsToolSearch: false },
|
| 484 |
+
};
|
| 485 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 486 |
+
const payload = await capturePayload<OpenAIPayload>(model, context);
|
| 487 |
+
|
| 488 |
+
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
|
| 489 |
+
expect(payload.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
| 490 |
+
});
|
| 491 |
+
|
| 492 |
+
it("selects additional tools, tool search, or top-level tools for Codex models", async () => {
|
| 493 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 494 |
+
const additionalTools = await capturePayload<OpenAIPayload>(
|
| 495 |
+
getModel("openai-codex", "gpt-5.6-sol"),
|
| 496 |
+
context,
|
| 497 |
+
makeCodexToken(),
|
| 498 |
+
);
|
| 499 |
+
const toolSearch = await capturePayload<OpenAIPayload>(
|
| 500 |
+
getModel("openai-codex", "gpt-5.5"),
|
| 501 |
+
context,
|
| 502 |
+
makeCodexToken(),
|
| 503 |
+
);
|
| 504 |
+
const topLevel = await capturePayload<OpenAIPayload>(
|
| 505 |
+
getModel("openai-codex", "gpt-5.3-codex-spark"),
|
| 506 |
+
context,
|
| 507 |
+
makeCodexToken(),
|
| 508 |
+
);
|
| 509 |
+
|
| 510 |
+
expect(openAIToolNames(additionalTools)).toEqual(["base_tool"]);
|
| 511 |
+
expect(additionalTools.input?.some((item) => item.type === "additional_tools")).toBe(true);
|
| 512 |
+
expect(additionalTools.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
| 513 |
+
expect(openAIToolNames(toolSearch)).toEqual(["base_tool"]);
|
| 514 |
+
expect(toolSearch.input?.some((item) => item.type === "tool_search_output")).toBe(true);
|
| 515 |
+
expect(openAIToolNames(topLevel)).toEqual(["base_tool", "late_tool"]);
|
| 516 |
+
expect(topLevel.input?.some((item) => item.type === "additional_tools")).toBe(false);
|
| 517 |
+
expect(topLevel.input?.some((item) => item.type === "tool_search_output")).toBe(false);
|
| 518 |
+
});
|
| 519 |
+
|
| 520 |
+
it("leaves providers without deferred loading unchanged", async () => {
|
| 521 |
+
const context = makeContext([makeTool("base_tool"), makeTool("late_tool")]);
|
| 522 |
+
const payload = await capturePayload<OpenAIPayload>(getModel("groq", "llama-3.3-70b-versatile"), context);
|
| 523 |
+
expect(openAIToolNames(payload)).toEqual(["base_tool", "late_tool"]);
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
it("counts definitions marked after the latest usage checkpoint", () => {
|
| 527 |
+
const assistant: AssistantMessage = {
|
| 528 |
+
...makeAssistantToolCall(),
|
| 529 |
+
content: [{ type: "text", text: "done" }],
|
| 530 |
+
usage: {
|
| 531 |
+
input: 50,
|
| 532 |
+
output: 50,
|
| 533 |
+
cacheRead: 0,
|
| 534 |
+
cacheWrite: 0,
|
| 535 |
+
totalTokens: 100,
|
| 536 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 537 |
+
},
|
| 538 |
+
stopReason: "stop",
|
| 539 |
+
};
|
| 540 |
+
const plain = estimateContextTokens({ messages: [assistant, makeUserMessage(4)], tools: [] });
|
| 541 |
+
const lateTool = { ...makeTool("late_tool"), description: "x".repeat(4000) };
|
| 542 |
+
const marked = estimateContextTokens({
|
| 543 |
+
messages: [assistant, makeToolResult(["late_tool"])],
|
| 544 |
+
tools: [lateTool],
|
| 545 |
+
});
|
| 546 |
+
|
| 547 |
+
expect(marked.tokens).toBeGreaterThan(plain.tokens + 500);
|
| 548 |
+
expect(marked.trailingTokens).toBeGreaterThan(plain.trailingTokens + 500);
|
| 549 |
+
});
|
| 550 |
+
});
|
packages/ai/test/empty.test.ts
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from "vitest";
|
| 2 |
+
import { complete, getModel } from "../src/compat.ts";
|
| 3 |
+
import type { Api, AssistantMessage, Context, Model, StreamOptions, UserMessage } from "../src/types.ts";
|
| 4 |
+
|
| 5 |
+
type StreamOptionsWithExtras = StreamOptions & Record<string, unknown>;
|
| 6 |
+
|
| 7 |
+
import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.ts";
|
| 8 |
+
import { hasBedrockCredentials } from "./bedrock-utils.ts";
|
| 9 |
+
import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.ts";
|
| 10 |
+
import { resolveApiKey } from "./oauth.ts";
|
| 11 |
+
|
| 12 |
+
// Resolve OAuth tokens at module level (async, runs before tests)
|
| 13 |
+
const oauthTokens = await Promise.all([
|
| 14 |
+
resolveApiKey("anthropic"),
|
| 15 |
+
resolveApiKey("github-copilot"),
|
| 16 |
+
resolveApiKey("openai-codex"),
|
| 17 |
+
]);
|
| 18 |
+
const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens;
|
| 19 |
+
|
| 20 |
+
async function testEmptyMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 21 |
+
// Test with completely empty content array
|
| 22 |
+
const emptyMessage: UserMessage = {
|
| 23 |
+
role: "user",
|
| 24 |
+
content: [],
|
| 25 |
+
timestamp: Date.now(),
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const context: Context = {
|
| 29 |
+
messages: [emptyMessage],
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const response = await complete(llm, context, options);
|
| 33 |
+
|
| 34 |
+
// Should either handle gracefully or return an error
|
| 35 |
+
expect(response).toBeDefined();
|
| 36 |
+
expect(response.role).toBe("assistant");
|
| 37 |
+
// Should handle empty string gracefully
|
| 38 |
+
if (response.stopReason === "error") {
|
| 39 |
+
expect(response.errorMessage).toBeDefined();
|
| 40 |
+
} else {
|
| 41 |
+
expect(response.content).toBeDefined();
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function testEmptyStringMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 46 |
+
// Test with empty string content
|
| 47 |
+
const context: Context = {
|
| 48 |
+
messages: [
|
| 49 |
+
{
|
| 50 |
+
role: "user",
|
| 51 |
+
content: "",
|
| 52 |
+
timestamp: Date.now(),
|
| 53 |
+
},
|
| 54 |
+
],
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
const response = await complete(llm, context, options);
|
| 58 |
+
|
| 59 |
+
expect(response).toBeDefined();
|
| 60 |
+
expect(response.role).toBe("assistant");
|
| 61 |
+
|
| 62 |
+
// Should handle empty string gracefully
|
| 63 |
+
if (response.stopReason === "error") {
|
| 64 |
+
expect(response.errorMessage).toBeDefined();
|
| 65 |
+
} else {
|
| 66 |
+
expect(response.content).toBeDefined();
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
async function testWhitespaceOnlyMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 71 |
+
// Test with whitespace-only content
|
| 72 |
+
const context: Context = {
|
| 73 |
+
messages: [
|
| 74 |
+
{
|
| 75 |
+
role: "user",
|
| 76 |
+
content: " \n\t ",
|
| 77 |
+
timestamp: Date.now(),
|
| 78 |
+
},
|
| 79 |
+
],
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
const response = await complete(llm, context, options);
|
| 83 |
+
|
| 84 |
+
expect(response).toBeDefined();
|
| 85 |
+
expect(response.role).toBe("assistant");
|
| 86 |
+
|
| 87 |
+
// Should handle whitespace-only gracefully
|
| 88 |
+
if (response.stopReason === "error") {
|
| 89 |
+
expect(response.errorMessage).toBeDefined();
|
| 90 |
+
} else {
|
| 91 |
+
expect(response.content).toBeDefined();
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
async function testEmptyAssistantMessage<TApi extends Api>(llm: Model<TApi>, options: StreamOptionsWithExtras = {}) {
|
| 96 |
+
// Test with empty assistant message in conversation flow
|
| 97 |
+
// User -> Empty Assistant -> User
|
| 98 |
+
const emptyAssistant: AssistantMessage = {
|
| 99 |
+
role: "assistant",
|
| 100 |
+
content: [],
|
| 101 |
+
api: llm.api,
|
| 102 |
+
provider: llm.provider,
|
| 103 |
+
model: llm.id,
|
| 104 |
+
usage: {
|
| 105 |
+
input: 10,
|
| 106 |
+
output: 0,
|
| 107 |
+
cacheRead: 0,
|
| 108 |
+
cacheWrite: 0,
|
| 109 |
+
totalTokens: 10,
|
| 110 |
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
| 111 |
+
},
|
| 112 |
+
stopReason: "stop",
|
| 113 |
+
timestamp: Date.now(),
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const context: Context = {
|
| 117 |
+
messages: [
|
| 118 |
+
{
|
| 119 |
+
role: "user",
|
| 120 |
+
content: "Hello, how are you?",
|
| 121 |
+
timestamp: Date.now(),
|
| 122 |
+
},
|
| 123 |
+
emptyAssistant,
|
| 124 |
+
{
|
| 125 |
+
role: "user",
|
| 126 |
+
content: "Please respond this time.",
|
| 127 |
+
timestamp: Date.now(),
|
| 128 |
+
},
|
| 129 |
+
],
|
| 130 |
+
};
|
| 131 |
+
|
| 132 |
+
const response = await complete(llm, context, options);
|
| 133 |
+
|
| 134 |
+
expect(response).toBeDefined();
|
| 135 |
+
expect(response.role).toBe("assistant");
|
| 136 |
+
|
| 137 |
+
// Should handle empty assistant message in context gracefully
|
| 138 |
+
if (response.stopReason === "error") {
|
| 139 |
+
expect(response.errorMessage).toBeDefined();
|
| 140 |
+
} else {
|
| 141 |
+
expect(response.content).toBeDefined();
|
| 142 |
+
expect(response.content.length).toBeGreaterThan(0);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
describe("AI Providers Empty Message Tests", () => {
|
| 147 |
+
describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider Empty Messages", () => {
|
| 148 |
+
const llm = getModel("google", "gemini-2.5-flash");
|
| 149 |
+
|
| 150 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 151 |
+
await testEmptyMessage(llm);
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 155 |
+
await testEmptyStringMessage(llm);
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 159 |
+
await testWhitespaceOnlyMessage(llm);
|
| 160 |
+
});
|
| 161 |
+
|
| 162 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 163 |
+
await testEmptyAssistantMessage(llm);
|
| 164 |
+
});
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions Provider Empty Messages", () => {
|
| 168 |
+
const llm = getModel("openai", "gpt-4o-mini");
|
| 169 |
+
|
| 170 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 171 |
+
await testEmptyMessage(llm);
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 175 |
+
await testEmptyStringMessage(llm);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 179 |
+
await testWhitespaceOnlyMessage(llm);
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 183 |
+
await testEmptyAssistantMessage(llm);
|
| 184 |
+
});
|
| 185 |
+
});
|
| 186 |
+
|
| 187 |
+
describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Responses Provider Empty Messages", () => {
|
| 188 |
+
const llm = getModel("openai", "gpt-5-mini");
|
| 189 |
+
|
| 190 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 191 |
+
await testEmptyMessage(llm);
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 195 |
+
await testEmptyStringMessage(llm);
|
| 196 |
+
});
|
| 197 |
+
|
| 198 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 199 |
+
await testWhitespaceOnlyMessage(llm);
|
| 200 |
+
});
|
| 201 |
+
|
| 202 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 203 |
+
await testEmptyAssistantMessage(llm);
|
| 204 |
+
});
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
describe.skipIf(!hasAzureOpenAICredentials())("Azure OpenAI Responses Provider Empty Messages", () => {
|
| 208 |
+
const llm = getModel("azure-openai-responses", "gpt-4o-mini");
|
| 209 |
+
const azureDeploymentName = resolveAzureDeploymentName(llm.id);
|
| 210 |
+
const azureOptions = azureDeploymentName ? { azureDeploymentName } : {};
|
| 211 |
+
|
| 212 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 213 |
+
await testEmptyMessage(llm, azureOptions);
|
| 214 |
+
});
|
| 215 |
+
|
| 216 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 217 |
+
await testEmptyStringMessage(llm, azureOptions);
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 221 |
+
await testWhitespaceOnlyMessage(llm, azureOptions);
|
| 222 |
+
});
|
| 223 |
+
|
| 224 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 225 |
+
await testEmptyAssistantMessage(llm, azureOptions);
|
| 226 |
+
});
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic Provider Empty Messages", () => {
|
| 230 |
+
const llm = getModel("anthropic", "claude-haiku-4-5");
|
| 231 |
+
|
| 232 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 233 |
+
await testEmptyMessage(llm);
|
| 234 |
+
});
|
| 235 |
+
|
| 236 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 237 |
+
await testEmptyStringMessage(llm);
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 241 |
+
await testWhitespaceOnlyMessage(llm);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 245 |
+
await testEmptyAssistantMessage(llm);
|
| 246 |
+
});
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
describe.skipIf(!process.env.XAI_API_KEY)("xAI Provider Empty Messages", () => {
|
| 250 |
+
const llm = getModel("xai", "grok-4.3");
|
| 251 |
+
|
| 252 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 253 |
+
await testEmptyMessage(llm);
|
| 254 |
+
});
|
| 255 |
+
|
| 256 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 257 |
+
await testEmptyStringMessage(llm);
|
| 258 |
+
});
|
| 259 |
+
|
| 260 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 261 |
+
await testWhitespaceOnlyMessage(llm);
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 265 |
+
await testEmptyAssistantMessage(llm);
|
| 266 |
+
});
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
describe.skipIf(!process.env.GROQ_API_KEY)("Groq Provider Empty Messages", () => {
|
| 270 |
+
const llm = getModel("groq", "openai/gpt-oss-20b");
|
| 271 |
+
|
| 272 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 273 |
+
await testEmptyMessage(llm);
|
| 274 |
+
});
|
| 275 |
+
|
| 276 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 277 |
+
await testEmptyStringMessage(llm);
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 281 |
+
await testWhitespaceOnlyMessage(llm);
|
| 282 |
+
});
|
| 283 |
+
|
| 284 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 285 |
+
await testEmptyAssistantMessage(llm);
|
| 286 |
+
});
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras Provider Empty Messages", () => {
|
| 290 |
+
const llm = getModel("cerebras", "gpt-oss-120b");
|
| 291 |
+
|
| 292 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 293 |
+
await testEmptyMessage(llm);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 297 |
+
await testEmptyStringMessage(llm);
|
| 298 |
+
});
|
| 299 |
+
|
| 300 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 301 |
+
await testWhitespaceOnlyMessage(llm);
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 305 |
+
await testEmptyAssistantMessage(llm);
|
| 306 |
+
});
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI Provider Empty Messages", () => {
|
| 310 |
+
const llm = getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6");
|
| 311 |
+
|
| 312 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 313 |
+
await testEmptyMessage(llm);
|
| 314 |
+
});
|
| 315 |
+
|
| 316 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 317 |
+
await testEmptyStringMessage(llm);
|
| 318 |
+
});
|
| 319 |
+
|
| 320 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 321 |
+
await testWhitespaceOnlyMessage(llm);
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 325 |
+
await testEmptyAssistantMessage(llm);
|
| 326 |
+
});
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider Empty Messages", () => {
|
| 330 |
+
const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6");
|
| 331 |
+
|
| 332 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 333 |
+
await testEmptyMessage(llm);
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 337 |
+
await testEmptyStringMessage(llm);
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 341 |
+
await testWhitespaceOnlyMessage(llm);
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 345 |
+
await testEmptyAssistantMessage(llm);
|
| 346 |
+
});
|
| 347 |
+
});
|
| 348 |
+
|
| 349 |
+
describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Empty Messages", () => {
|
| 350 |
+
const llm = getModel("huggingface", "moonshotai/Kimi-K2.5");
|
| 351 |
+
|
| 352 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 353 |
+
await testEmptyMessage(llm);
|
| 354 |
+
});
|
| 355 |
+
|
| 356 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 357 |
+
await testEmptyStringMessage(llm);
|
| 358 |
+
});
|
| 359 |
+
|
| 360 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 361 |
+
await testWhitespaceOnlyMessage(llm);
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 365 |
+
await testEmptyAssistantMessage(llm);
|
| 366 |
+
});
|
| 367 |
+
});
|
| 368 |
+
|
| 369 |
+
describe.skipIf(!process.env.TOGETHER_API_KEY)("Together AI Provider Empty Messages", () => {
|
| 370 |
+
const llm = getModel("together", "moonshotai/Kimi-K2.6");
|
| 371 |
+
|
| 372 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 373 |
+
await testEmptyMessage(llm);
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 377 |
+
await testEmptyStringMessage(llm);
|
| 378 |
+
});
|
| 379 |
+
|
| 380 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 381 |
+
await testWhitespaceOnlyMessage(llm);
|
| 382 |
+
});
|
| 383 |
+
|
| 384 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 385 |
+
await testEmptyAssistantMessage(llm);
|
| 386 |
+
});
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
describe.skipIf(!process.env.BASETEN_API_KEY)("Baseten Provider Empty Messages", () => {
|
| 390 |
+
const llm = getModel("baseten", "zai-org/GLM-5.2");
|
| 391 |
+
const options = { reasoningEffort: "high" } satisfies StreamOptionsWithExtras;
|
| 392 |
+
|
| 393 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 394 |
+
await testEmptyMessage(llm, options);
|
| 395 |
+
});
|
| 396 |
+
|
| 397 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 398 |
+
await testEmptyStringMessage(llm, options);
|
| 399 |
+
});
|
| 400 |
+
|
| 401 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 402 |
+
await testWhitespaceOnlyMessage(llm, options);
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 406 |
+
await testEmptyAssistantMessage(llm, options);
|
| 407 |
+
});
|
| 408 |
+
});
|
| 409 |
+
|
| 410 |
+
describe.skipIf(!process.env.ZAI_API_KEY)("zAI Provider Empty Messages", () => {
|
| 411 |
+
const llm = getModel("zai", "glm-5.2");
|
| 412 |
+
|
| 413 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 414 |
+
await testEmptyMessage(llm);
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 418 |
+
await testEmptyStringMessage(llm);
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 422 |
+
await testWhitespaceOnlyMessage(llm);
|
| 423 |
+
});
|
| 424 |
+
|
| 425 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 426 |
+
await testEmptyAssistantMessage(llm);
|
| 427 |
+
});
|
| 428 |
+
});
|
| 429 |
+
|
| 430 |
+
describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral Provider Empty Messages", () => {
|
| 431 |
+
const llm = getModel("mistral", "devstral-medium-latest");
|
| 432 |
+
|
| 433 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 434 |
+
await testEmptyMessage(llm);
|
| 435 |
+
});
|
| 436 |
+
|
| 437 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 438 |
+
await testEmptyStringMessage(llm);
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 442 |
+
await testWhitespaceOnlyMessage(llm);
|
| 443 |
+
});
|
| 444 |
+
|
| 445 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 446 |
+
await testEmptyAssistantMessage(llm);
|
| 447 |
+
});
|
| 448 |
+
});
|
| 449 |
+
|
| 450 |
+
describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax Provider Empty Messages", () => {
|
| 451 |
+
const llm = getModel("minimax", "MiniMax-M2.7");
|
| 452 |
+
|
| 453 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 454 |
+
await testEmptyMessage(llm);
|
| 455 |
+
});
|
| 456 |
+
|
| 457 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 458 |
+
await testEmptyStringMessage(llm);
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 462 |
+
await testWhitespaceOnlyMessage(llm);
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 466 |
+
await testEmptyAssistantMessage(llm);
|
| 467 |
+
});
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider Empty Messages", () => {
|
| 471 |
+
const llm = getModel("xiaomi", "mimo-v2.5-pro");
|
| 472 |
+
|
| 473 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 474 |
+
await testEmptyMessage(llm);
|
| 475 |
+
});
|
| 476 |
+
|
| 477 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 478 |
+
await testEmptyStringMessage(llm);
|
| 479 |
+
});
|
| 480 |
+
|
| 481 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 482 |
+
await testWhitespaceOnlyMessage(llm);
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 486 |
+
await testEmptyAssistantMessage(llm);
|
| 487 |
+
});
|
| 488 |
+
});
|
| 489 |
+
|
| 490 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)(
|
| 491 |
+
"Xiaomi MiMo Token Plan (CN) Provider Empty Messages",
|
| 492 |
+
() => {
|
| 493 |
+
const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro");
|
| 494 |
+
|
| 495 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 496 |
+
await testEmptyMessage(llm);
|
| 497 |
+
});
|
| 498 |
+
|
| 499 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 500 |
+
await testEmptyStringMessage(llm);
|
| 501 |
+
});
|
| 502 |
+
|
| 503 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 504 |
+
await testWhitespaceOnlyMessage(llm);
|
| 505 |
+
});
|
| 506 |
+
|
| 507 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 508 |
+
await testEmptyAssistantMessage(llm);
|
| 509 |
+
});
|
| 510 |
+
},
|
| 511 |
+
);
|
| 512 |
+
|
| 513 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)(
|
| 514 |
+
"Xiaomi MiMo Token Plan (AMS) Provider Empty Messages",
|
| 515 |
+
() => {
|
| 516 |
+
const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro");
|
| 517 |
+
|
| 518 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 519 |
+
await testEmptyMessage(llm);
|
| 520 |
+
});
|
| 521 |
+
|
| 522 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 523 |
+
await testEmptyStringMessage(llm);
|
| 524 |
+
});
|
| 525 |
+
|
| 526 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 527 |
+
await testWhitespaceOnlyMessage(llm);
|
| 528 |
+
});
|
| 529 |
+
|
| 530 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 531 |
+
await testEmptyAssistantMessage(llm);
|
| 532 |
+
});
|
| 533 |
+
},
|
| 534 |
+
);
|
| 535 |
+
|
| 536 |
+
describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)(
|
| 537 |
+
"Xiaomi MiMo Token Plan (SGP) Provider Empty Messages",
|
| 538 |
+
() => {
|
| 539 |
+
const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro");
|
| 540 |
+
|
| 541 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 542 |
+
await testEmptyMessage(llm);
|
| 543 |
+
});
|
| 544 |
+
|
| 545 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 546 |
+
await testEmptyStringMessage(llm);
|
| 547 |
+
});
|
| 548 |
+
|
| 549 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 550 |
+
await testWhitespaceOnlyMessage(llm);
|
| 551 |
+
});
|
| 552 |
+
|
| 553 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 554 |
+
await testEmptyAssistantMessage(llm);
|
| 555 |
+
});
|
| 556 |
+
},
|
| 557 |
+
);
|
| 558 |
+
|
| 559 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Provider Empty Messages", () => {
|
| 560 |
+
const llm = getModel("qwen-token-plan", "qwen3.7-max");
|
| 561 |
+
|
| 562 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 563 |
+
await testEmptyMessage(llm);
|
| 564 |
+
});
|
| 565 |
+
|
| 566 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 567 |
+
await testEmptyStringMessage(llm);
|
| 568 |
+
});
|
| 569 |
+
|
| 570 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 571 |
+
await testWhitespaceOnlyMessage(llm);
|
| 572 |
+
});
|
| 573 |
+
|
| 574 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 575 |
+
await testEmptyAssistantMessage(llm);
|
| 576 |
+
});
|
| 577 |
+
});
|
| 578 |
+
|
| 579 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_API_KEY)("Qwen Token Plan Individual Provider Empty Messages", () => {
|
| 580 |
+
const llm = getModel("qwen-token-plan-individual", "qwen3.8-max");
|
| 581 |
+
|
| 582 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 583 |
+
await testEmptyMessage(llm);
|
| 584 |
+
});
|
| 585 |
+
|
| 586 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 587 |
+
await testEmptyStringMessage(llm);
|
| 588 |
+
});
|
| 589 |
+
|
| 590 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 591 |
+
await testWhitespaceOnlyMessage(llm);
|
| 592 |
+
});
|
| 593 |
+
|
| 594 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 595 |
+
await testEmptyAssistantMessage(llm);
|
| 596 |
+
});
|
| 597 |
+
});
|
| 598 |
+
|
| 599 |
+
describe.skipIf(!process.env.QWEN_TOKEN_PLAN_CN_API_KEY)("Qwen Token Plan (CN) Provider Empty Messages", () => {
|
| 600 |
+
const llm = getModel("qwen-token-plan-cn", "qwen3.7-max");
|
| 601 |
+
|
| 602 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 603 |
+
await testEmptyMessage(llm);
|
| 604 |
+
});
|
| 605 |
+
|
| 606 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 607 |
+
await testEmptyStringMessage(llm);
|
| 608 |
+
});
|
| 609 |
+
|
| 610 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 611 |
+
await testWhitespaceOnlyMessage(llm);
|
| 612 |
+
});
|
| 613 |
+
|
| 614 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 615 |
+
await testEmptyAssistantMessage(llm);
|
| 616 |
+
});
|
| 617 |
+
});
|
| 618 |
+
|
| 619 |
+
describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding Provider Empty Messages", () => {
|
| 620 |
+
const llm = getModel("kimi-coding", "kimi-for-coding");
|
| 621 |
+
|
| 622 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 623 |
+
await testEmptyMessage(llm);
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 627 |
+
await testEmptyStringMessage(llm);
|
| 628 |
+
});
|
| 629 |
+
|
| 630 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 631 |
+
await testWhitespaceOnlyMessage(llm);
|
| 632 |
+
});
|
| 633 |
+
|
| 634 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 635 |
+
await testEmptyAssistantMessage(llm);
|
| 636 |
+
});
|
| 637 |
+
});
|
| 638 |
+
|
| 639 |
+
describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway Provider Empty Messages", () => {
|
| 640 |
+
const llm = getModel("vercel-ai-gateway", "google/gemini-2.5-flash");
|
| 641 |
+
|
| 642 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 643 |
+
await testEmptyMessage(llm);
|
| 644 |
+
});
|
| 645 |
+
|
| 646 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 647 |
+
await testEmptyStringMessage(llm);
|
| 648 |
+
});
|
| 649 |
+
|
| 650 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 651 |
+
await testWhitespaceOnlyMessage(llm);
|
| 652 |
+
});
|
| 653 |
+
|
| 654 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 655 |
+
await testEmptyAssistantMessage(llm);
|
| 656 |
+
});
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock Provider Empty Messages", () => {
|
| 660 |
+
const llm = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0");
|
| 661 |
+
|
| 662 |
+
it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 663 |
+
await testEmptyMessage(llm);
|
| 664 |
+
});
|
| 665 |
+
|
| 666 |
+
it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 667 |
+
await testEmptyStringMessage(llm);
|
| 668 |
+
});
|
| 669 |
+
|
| 670 |
+
it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => {
|
| 671 |
+
await testWhitespaceOnlyMessage(llm);
|
| 672 |
+
});
|
| 673 |
+
|
| 674 |
+
it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => {
|
| 675 |
+
await testEmptyAssistantMessage(llm);
|
| 676 |
+
});
|
| 677 |
+
});
|
| 678 |
+
|
| 679 |
+
// =========================================================================
|
| 680 |
+
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
|
| 681 |
+
// =========================================================================
|
| 682 |
+
|
| 683 |
+
describe("Anthropic OAuth Provider Empty Messages", () => {
|
| 684 |
+
const llm = getModel("anthropic", "claude-haiku-4-5");
|
| 685 |
+
|
| 686 |
+
it.skipIf(!anthropicOAuthToken)("should handle empty content array", { retry: 3, timeout: 30000 }, async () => {
|
| 687 |
+
await testEmptyMessage(llm, { apiKey: anthropicOAuthToken });
|
| 688 |
+
});
|
| 689 |
+
|
| 690 |
+
it.skipIf(!anthropicOAuthToken)("should handle empty string content", { retry: 3, timeout: 30000 }, async () => {
|
| 691 |
+
await testEmptyStringMessage(llm, { apiKey: anthropicOAuthToken });
|
| 692 |
+
});
|
| 693 |
+
|
| 694 |
+
it.skipIf(!anthropicOAuthToken)(
|
| 695 |
+
"should handle whitespace-only content",
|
| 696 |
+
{ retry: 3, timeout: 30000 },
|
| 697 |
+
async () => {
|
| 698 |
+
await testWhitespaceOnlyMessage(llm, { apiKey: anthropicOAuthToken });
|
| 699 |
+
},
|
| 700 |
+
);
|
| 701 |
+
|
| 702 |
+
it.skipIf(!anthropicOAuthToken)(
|
| 703 |
+
"should handle empty assistant message in conversation",
|
| 704 |
+
{ retry: 3, timeout: 30000 },
|
| 705 |
+
async () => {
|
| 706 |
+
await testEmptyAssistantMessage(llm, { apiKey: anthropicOAuthToken });
|
| 707 |
+
},
|
| 708 |
+
);
|
| 709 |
+
});
|
| 710 |
+
|
| 711 |
+
describe("GitHub Copilot Provider Empty Messages", () => {
|
| 712 |
+
it.skipIf(!githubCopilotToken)(
|
| 713 |
+
"claude-haiku-4.5 - should handle empty content array",
|
| 714 |
+
{ retry: 3, timeout: 30000 },
|
| 715 |
+
async () => {
|
| 716 |
+
const llm = getModel("github-copilot", "claude-haiku-4.5");
|
| 717 |
+
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
|
| 718 |
+
},
|
| 719 |
+
);
|
| 720 |
+
|
| 721 |
+
it.skipIf(!githubCopilotToken)(
|
| 722 |
+
"claude-haiku-4.5 - should handle empty string content",
|
| 723 |
+
{ retry: 3, timeout: 30000 },
|
| 724 |
+
async () => {
|
| 725 |
+
const llm = getModel("github-copilot", "claude-haiku-4.5");
|
| 726 |
+
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
|
| 727 |
+
},
|
| 728 |
+
);
|
| 729 |
+
|
| 730 |
+
it.skipIf(!githubCopilotToken)(
|
| 731 |
+
"claude-haiku-4.5 - should handle whitespace-only content",
|
| 732 |
+
{ retry: 3, timeout: 30000 },
|
| 733 |
+
async () => {
|
| 734 |
+
const llm = getModel("github-copilot", "claude-haiku-4.5");
|
| 735 |
+
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
|
| 736 |
+
},
|
| 737 |
+
);
|
| 738 |
+
|
| 739 |
+
it.skipIf(!githubCopilotToken)(
|
| 740 |
+
"claude-haiku-4.5 - should handle empty assistant message in conversation",
|
| 741 |
+
{ retry: 3, timeout: 30000 },
|
| 742 |
+
async () => {
|
| 743 |
+
const llm = getModel("github-copilot", "claude-haiku-4.5");
|
| 744 |
+
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
|
| 745 |
+
},
|
| 746 |
+
);
|
| 747 |
+
|
| 748 |
+
it.skipIf(!githubCopilotToken)(
|
| 749 |
+
"claude-sonnet-4 - should handle empty content array",
|
| 750 |
+
{ retry: 3, timeout: 30000 },
|
| 751 |
+
async () => {
|
| 752 |
+
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
| 753 |
+
await testEmptyMessage(llm, { apiKey: githubCopilotToken });
|
| 754 |
+
},
|
| 755 |
+
);
|
| 756 |
+
|
| 757 |
+
it.skipIf(!githubCopilotToken)(
|
| 758 |
+
"claude-sonnet-4 - should handle empty string content",
|
| 759 |
+
{ retry: 3, timeout: 30000 },
|
| 760 |
+
async () => {
|
| 761 |
+
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
| 762 |
+
await testEmptyStringMessage(llm, { apiKey: githubCopilotToken });
|
| 763 |
+
},
|
| 764 |
+
);
|
| 765 |
+
|
| 766 |
+
it.skipIf(!githubCopilotToken)(
|
| 767 |
+
"claude-sonnet-4 - should handle whitespace-only content",
|
| 768 |
+
{ retry: 3, timeout: 30000 },
|
| 769 |
+
async () => {
|
| 770 |
+
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
| 771 |
+
await testWhitespaceOnlyMessage(llm, { apiKey: githubCopilotToken });
|
| 772 |
+
},
|
| 773 |
+
);
|
| 774 |
+
|
| 775 |
+
it.skipIf(!githubCopilotToken)(
|
| 776 |
+
"claude-sonnet-4 - should handle empty assistant message in conversation",
|
| 777 |
+
{ retry: 3, timeout: 30000 },
|
| 778 |
+
async () => {
|
| 779 |
+
const llm = getModel("github-copilot", "claude-sonnet-4.6");
|
| 780 |
+
await testEmptyAssistantMessage(llm, { apiKey: githubCopilotToken });
|
| 781 |
+
},
|
| 782 |
+
);
|
| 783 |
+
});
|
| 784 |
+
|
| 785 |
+
describe("OpenAI Codex Provider Empty Messages", () => {
|
| 786 |
+
it.skipIf(!openaiCodexToken)(
|
| 787 |
+
"gpt-5.5 - should handle empty content array",
|
| 788 |
+
{ retry: 3, timeout: 30000 },
|
| 789 |
+
async () => {
|
| 790 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 791 |
+
await testEmptyMessage(llm, { apiKey: openaiCodexToken });
|
| 792 |
+
},
|
| 793 |
+
);
|
| 794 |
+
|
| 795 |
+
it.skipIf(!openaiCodexToken)(
|
| 796 |
+
"gpt-5.5 - should handle empty string content",
|
| 797 |
+
{ retry: 3, timeout: 30000 },
|
| 798 |
+
async () => {
|
| 799 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 800 |
+
await testEmptyStringMessage(llm, { apiKey: openaiCodexToken });
|
| 801 |
+
},
|
| 802 |
+
);
|
| 803 |
+
|
| 804 |
+
it.skipIf(!openaiCodexToken)(
|
| 805 |
+
"gpt-5.5 - should handle whitespace-only content",
|
| 806 |
+
{ retry: 3, timeout: 30000 },
|
| 807 |
+
async () => {
|
| 808 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 809 |
+
await testWhitespaceOnlyMessage(llm, { apiKey: openaiCodexToken });
|
| 810 |
+
},
|
| 811 |
+
);
|
| 812 |
+
|
| 813 |
+
it.skipIf(!openaiCodexToken)(
|
| 814 |
+
"gpt-5.5 - should handle empty assistant message in conversation",
|
| 815 |
+
{ retry: 3, timeout: 30000 },
|
| 816 |
+
async () => {
|
| 817 |
+
const llm = getModel("openai-codex", "gpt-5.5");
|
| 818 |
+
await testEmptyAssistantMessage(llm, { apiKey: openaiCodexToken });
|
| 819 |
+
},
|
| 820 |
+
);
|
| 821 |
+
});
|
| 822 |
+
});
|