Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2063
packages/ai/CHANGELOG.md
Normal file
2063
packages/ai/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
1713
packages/ai/README.md
Normal file
1713
packages/ai/README.md
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/ai/bedrock-provider.d.ts
vendored
Normal file
1
packages/ai/bedrock-provider.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./dist/bedrock-provider.js";
|
||||
1
packages/ai/bedrock-provider.js
Normal file
1
packages/ai/bedrock-provider.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./dist/bedrock-provider.js";
|
||||
103
packages/ai/package.json
Normal file
103
packages/ai/package.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "@earendil-works/pi-ai",
|
||||
"version": "0.85.1",
|
||||
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"sideEffects": [
|
||||
"./dist/compat.js",
|
||||
"./dist/images.js",
|
||||
"./dist/providers/images/register-builtins.js"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./compat": {
|
||||
"types": "./dist/compat.d.ts",
|
||||
"import": "./dist/compat.js"
|
||||
},
|
||||
"./providers/*": {
|
||||
"types": "./dist/providers/*.d.ts",
|
||||
"import": "./dist/providers/*.js"
|
||||
},
|
||||
"./api/*": {
|
||||
"types": "./dist/api/*.d.ts",
|
||||
"import": "./dist/api/*.js"
|
||||
},
|
||||
"./utils/*": {
|
||||
"types": "./dist/utils/*.d.ts",
|
||||
"import": "./dist/utils/*.js"
|
||||
},
|
||||
"./oauth": {
|
||||
"types": "./dist/oauth.d.ts",
|
||||
"import": "./dist/oauth.js"
|
||||
},
|
||||
"./bedrock-provider": {
|
||||
"types": "./dist/bedrock-provider.d.ts",
|
||||
"import": "./dist/bedrock-provider.js"
|
||||
},
|
||||
"./bun-oauth": {
|
||||
"types": "./dist/bun-oauth.d.ts",
|
||||
"import": "./dist/bun-oauth.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"pi-ai": "dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "shx rm -rf dist",
|
||||
"generate-models": "node scripts/generate-models.ts --strict",
|
||||
"hydrate-model-data": "node scripts/generate-models.ts --strict --data-only",
|
||||
"generate-model-catalog": "node scripts/generate-models.ts --strict --json-only --json-output ../../.artifacts/model-catalog",
|
||||
"generate-image-models": "node scripts/generate-image-models.ts --strict",
|
||||
"check:model-data": "node scripts/check-model-data.ts",
|
||||
"build": "npm run generate-models && npm run build:offline",
|
||||
"build:offline": "npm run check:model-data && tsgo -p tsconfig.build.json && shx rm -rf dist/providers/data && shx cp -r src/providers/data dist/providers/data",
|
||||
"test": "vitest --run",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "0.123.0",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.1048.0",
|
||||
"@earendil-works/pi-telemetry": "^0.85.1",
|
||||
"@google/genai": "1.52.0",
|
||||
"@smithy/node-http-handler": "4.7.3",
|
||||
"http-proxy-agent": "7.0.2",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"openai": "6.40.0",
|
||||
"partial-json": "0.1.7",
|
||||
"typebox": "1.3.7"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"llm",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"gemini",
|
||||
"bedrock",
|
||||
"unified",
|
||||
"api"
|
||||
],
|
||||
"author": "Mario Zechner",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/earendil-works/pi.git",
|
||||
"directory": "packages/ai"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.19",
|
||||
"canvas": "3.2.3",
|
||||
"vitest": "4.1.9"
|
||||
}
|
||||
}
|
||||
16
packages/ai/scripts/check-model-data.ts
Normal file
16
packages/ai/scripts/check-model-data.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { validateGeneratedModelData } from "./model-data.ts";
|
||||
|
||||
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
try {
|
||||
validateGeneratedModelData(packageRoot);
|
||||
console.log("Generated model data is valid.");
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
console.error("\nModel data is missing or stale. Run `npm run hydrate:model-data` from the repository root.");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
162
packages/ai/scripts/generate-image-models.ts
Normal file
162
packages/ai/scripts/generate-image-models.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { writeFileSync } from "fs";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import type { ImagesModel } from "../src/types.ts";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const packageRoot = join(__dirname, "..");
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
|
||||
function readStrictOption(args: string[]): boolean {
|
||||
for (const arg of args) {
|
||||
if (arg !== "--strict") throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return args.includes("--strict");
|
||||
}
|
||||
|
||||
interface OpenRouterModelRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
context_length?: number;
|
||||
architecture?: {
|
||||
input_modalities?: string[];
|
||||
output_modalities?: string[];
|
||||
};
|
||||
pricing?: {
|
||||
prompt?: string;
|
||||
completion?: string;
|
||||
input_cache_read?: string;
|
||||
input_cache_write?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOpenRouterImageModels(
|
||||
payload: unknown,
|
||||
strict: boolean,
|
||||
): ImagesModel<"openrouter-images">[] {
|
||||
const data =
|
||||
typeof payload === "object" && payload !== null
|
||||
? (payload as { data?: OpenRouterModelRecord[] }).data
|
||||
: undefined;
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
if (strict) throw new Error("OpenRouter API returned a missing or empty image model list");
|
||||
return [];
|
||||
}
|
||||
|
||||
const models: ImagesModel<"openrouter-images">[] = [];
|
||||
for (const model of data) {
|
||||
const input = Array.from(
|
||||
new Set(
|
||||
(model.architecture?.input_modalities ?? []).filter(
|
||||
(modality): modality is "text" | "image" => modality === "text" || modality === "image",
|
||||
),
|
||||
),
|
||||
);
|
||||
const output = Array.from(
|
||||
new Set(
|
||||
(model.architecture?.output_modalities ?? []).filter(
|
||||
(modality): modality is "text" | "image" => modality === "text" || modality === "image",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!output.includes("image")) continue;
|
||||
if (input.length === 0) input.push("text");
|
||||
|
||||
models.push({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
input,
|
||||
output,
|
||||
cost: {
|
||||
input: parseFloat(model.pricing?.prompt || "0") * 1_000_000,
|
||||
output: parseFloat(model.pricing?.completion || "0") * 1_000_000,
|
||||
cacheRead: parseFloat(model.pricing?.input_cache_read || "0") * 1_000_000,
|
||||
cacheWrite: parseFloat(model.pricing?.input_cache_write || "0") * 1_000_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (strict && models.length === 0) {
|
||||
throw new Error("OpenRouter API returned no usable image models");
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
async function fetchOpenRouterImageModels(strict: boolean): Promise<ImagesModel<"openrouter-images">[]> {
|
||||
try {
|
||||
console.log("Fetching image models from OpenRouter API...");
|
||||
const response = await fetch(`${OPENROUTER_BASE_URL}/models?output_modalities=image`);
|
||||
if (!response.ok) throw new Error(`OpenRouter API returned ${response.status}`);
|
||||
const models = parseOpenRouterImageModels(await response.json(), strict);
|
||||
console.log(`Fetched ${models.length} image models from OpenRouter`);
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch OpenRouter image models:", error);
|
||||
if (strict) throw error;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function generateImageModelsFile(models: ImagesModel<"openrouter-images">[]): string {
|
||||
const imageModelsByProvider = {
|
||||
openrouter: Object.fromEntries(
|
||||
models
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map((model) => [
|
||||
model.id,
|
||||
`{
|
||||
id: ${JSON.stringify(model.id)},
|
||||
name: ${JSON.stringify(model.name)},
|
||||
api: ${JSON.stringify(model.api)},
|
||||
provider: ${JSON.stringify(model.provider)},
|
||||
baseUrl: ${JSON.stringify(model.baseUrl)},
|
||||
input: ${JSON.stringify(model.input)},
|
||||
output: ${JSON.stringify(model.output)},
|
||||
cost: ${JSON.stringify(model.cost, null, 2).replace(/^/gm, "\t")}
|
||||
} satisfies ImagesModel<${JSON.stringify(model.api)}>`,
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
const providerEntries = Object.entries(imageModelsByProvider)
|
||||
.map(([provider, providerModels]) => {
|
||||
const modelEntries = Object.entries(providerModels)
|
||||
.map(([id, serialized]) => `\t\t${JSON.stringify(id)}: ${serialized},`)
|
||||
.join("\n");
|
||||
return `\t${JSON.stringify(provider)}: {\n${modelEntries}\n\t},`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `// This file is auto-generated by scripts/generate-image-models.ts
|
||||
// Do not edit manually - run 'npm run generate-image-models' to update
|
||||
|
||||
import type { ImagesApi, ImagesModel } from "./types.ts";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
${providerEntries}
|
||||
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
|
||||
`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const strict = readStrictOption(process.argv.slice(2));
|
||||
const models = await fetchOpenRouterImageModels(strict);
|
||||
const output = generateImageModelsFile(models);
|
||||
const outputPath = join(packageRoot, "src", "image-models.generated.ts");
|
||||
writeFileSync(outputPath, output, "utf-8");
|
||||
console.log(`Generated ${outputPath}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === __filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
3131
packages/ai/scripts/generate-models.ts
Normal file
3131
packages/ai/scripts/generate-models.ts
Normal file
File diff suppressed because it is too large
Load Diff
33
packages/ai/scripts/generate-test-image.ts
Normal file
33
packages/ai/scripts/generate-test-image.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createCanvas } from "canvas";
|
||||
import { mkdirSync, writeFileSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Create a 200x200 canvas
|
||||
const canvas = createCanvas(200, 200);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// Fill background with white
|
||||
ctx.fillStyle = "white";
|
||||
ctx.fillRect(0, 0, 200, 200);
|
||||
|
||||
// Draw a red circle in the center
|
||||
ctx.fillStyle = "red";
|
||||
ctx.beginPath();
|
||||
ctx.arc(100, 100, 50, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Save the image
|
||||
const buffer = canvas.toBuffer("image/png");
|
||||
const outputPath = join(__dirname, "..", "test", "data", "red-circle.png");
|
||||
|
||||
// Ensure the directory exists
|
||||
mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true });
|
||||
|
||||
writeFileSync(outputPath, buffer);
|
||||
console.log(`Generated test image at: ${outputPath}`);
|
||||
280
packages/ai/scripts/model-data.ts
Normal file
280
packages/ai/scripts/model-data.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const MODEL_DATA_SCHEMA_VERSION = 3;
|
||||
export const MODEL_DATA_MANIFEST_FILE = ".manifest.json";
|
||||
|
||||
export type ModelDataStructure = Record<string, Record<string, string>>;
|
||||
|
||||
export interface ModelDataManifest {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
structureHash: string;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
const MODEL_DATA_IMPORT_PATTERN =
|
||||
/^import \{ [A-Z][A-Z0-9_]*_MODELS \} from "\.\/providers\/([^"/]+)\.models\.ts";$/gm;
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function sortedRecord<T>(entries: Iterable<readonly [string, T]>): Record<string, T> {
|
||||
return Object.fromEntries(Array.from(entries).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
||||
}
|
||||
|
||||
function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
}
|
||||
|
||||
function describeSetDifference(expected: readonly string[], actual: readonly string[]): string {
|
||||
const expectedSet = new Set(expected);
|
||||
const actualSet = new Set(actual);
|
||||
const missing = expected.filter((value) => !actualSet.has(value));
|
||||
const extra = actual.filter((value) => !expectedSet.has(value));
|
||||
return [missing.length > 0 ? `missing: ${missing.join(", ")}` : "", extra.length > 0 ? `extra: ${extra.join(", ")}` : ""]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
export function assertExactModelIds(label: string, expected: Iterable<string>, actual: Iterable<string>): void {
|
||||
const expectedIds = Array.from(new Set(expected)).sort();
|
||||
const actualIds = Array.from(new Set(actual)).sort();
|
||||
if (sameStrings(expectedIds, actualIds)) return;
|
||||
throw new Error(`${label} model IDs do not match (${describeSetDifference(expectedIds, actualIds)})`);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readJsonObject(path: string, description: string, errors: string[]): Record<string, unknown> | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (error) {
|
||||
errors.push(`${description} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
errors.push(`${description} must contain a JSON object`);
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readProviderStructure(path: string, providerId: string): Record<string, string> {
|
||||
const errors: string[] = [];
|
||||
const groups = readJsonObject(path, `${providerId}.json`, errors);
|
||||
if (!groups) throw new Error(errors.join("\n"));
|
||||
|
||||
const models = new Map<string, string>();
|
||||
for (const [api, value] of Object.entries(groups)) {
|
||||
if (!isRecord(value)) throw new Error(`${path} API group ${JSON.stringify(api)} must be an object`);
|
||||
for (const modelId of Object.keys(value)) {
|
||||
if (models.has(modelId)) throw new Error(`${path} contains model ${modelId} in more than one API group`);
|
||||
models.set(modelId, api);
|
||||
}
|
||||
}
|
||||
if (models.size === 0) throw new Error(`${path} contains no generated model data`);
|
||||
return sortedRecord(models);
|
||||
}
|
||||
|
||||
export function readModelDataProviderIds(packageRoot: string): string[] {
|
||||
const aggregatorPath = join(packageRoot, "src", "models.generated.ts");
|
||||
const aggregator = readFileSync(aggregatorPath, "utf8");
|
||||
const providerIds = Array.from(aggregator.matchAll(MODEL_DATA_IMPORT_PATTERN), (match) => match[1]).sort();
|
||||
if (providerIds.length === 0) throw new Error(`No generated provider imports found in ${aggregatorPath}`);
|
||||
if (new Set(providerIds).size !== providerIds.length) {
|
||||
throw new Error(`Generated model aggregator contains duplicate provider imports: ${aggregatorPath}`);
|
||||
}
|
||||
return providerIds;
|
||||
}
|
||||
|
||||
export function readModelDataStructure(packageRoot: string): ModelDataStructure {
|
||||
const providersDir = join(packageRoot, "src", "providers");
|
||||
const dataDir = join(providersDir, "data");
|
||||
const providerIds = readModelDataProviderIds(packageRoot);
|
||||
const expectedShards = providerIds.map((providerId) => `${providerId}.models.ts`).sort();
|
||||
const actualShards = readdirSync(providersDir)
|
||||
.filter((entry) => entry.endsWith(".models.ts"))
|
||||
.sort();
|
||||
if (!sameStrings(expectedShards, actualShards)) {
|
||||
throw new Error(
|
||||
`Generated model aggregator and provider shards do not match (${describeSetDifference(expectedShards, actualShards)})`,
|
||||
);
|
||||
}
|
||||
|
||||
return sortedRecord(
|
||||
providerIds.map((providerId) => [
|
||||
providerId,
|
||||
readProviderStructure(join(dataDir, `${providerId}.json`), providerId),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function modelDataStructureHash(structure: ModelDataStructure): string {
|
||||
const normalized = sortedRecord(
|
||||
Object.entries(structure).map(
|
||||
([providerId, models]) => [providerId, sortedRecord(Object.entries(models))] as const,
|
||||
),
|
||||
);
|
||||
return sha256(JSON.stringify(normalized));
|
||||
}
|
||||
|
||||
export function createModelDataManifest(
|
||||
structure: ModelDataStructure,
|
||||
fileContents: Readonly<Record<string, string>>,
|
||||
generatedAt: string,
|
||||
): ModelDataManifest {
|
||||
return {
|
||||
schemaVersion: MODEL_DATA_SCHEMA_VERSION,
|
||||
generatedAt,
|
||||
structureHash: modelDataStructureHash(structure),
|
||||
files: sortedRecord(Object.entries(fileContents).map(([file, content]) => [file, sha256(content)] as const)),
|
||||
};
|
||||
}
|
||||
|
||||
function validateModelValue(
|
||||
value: unknown,
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
expectedApi: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
const label = `${providerId}/${modelId}`;
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${label} must be an object`);
|
||||
return;
|
||||
}
|
||||
if (value.id !== modelId) errors.push(`${label} has id ${JSON.stringify(value.id)}, expected ${JSON.stringify(modelId)}`);
|
||||
if (value.provider !== providerId) {
|
||||
errors.push(`${label} has provider ${JSON.stringify(value.provider)}, expected ${JSON.stringify(providerId)}`);
|
||||
}
|
||||
if (value.api !== expectedApi) {
|
||||
errors.push(`${label} has api ${JSON.stringify(value.api)}, expected ${JSON.stringify(expectedApi)}`);
|
||||
}
|
||||
if (typeof value.name !== "string" || value.name.length === 0) errors.push(`${label} has no model name`);
|
||||
if (typeof value.baseUrl !== "string") errors.push(`${label} has no baseUrl string`);
|
||||
if (typeof value.reasoning !== "boolean") errors.push(`${label} has no reasoning boolean`);
|
||||
if (
|
||||
!Array.isArray(value.input) ||
|
||||
value.input.length === 0 ||
|
||||
value.input.some((entry) => entry !== "text" && entry !== "image")
|
||||
) {
|
||||
errors.push(`${label} has invalid input modalities`);
|
||||
}
|
||||
if (typeof value.contextWindow !== "number" || !Number.isFinite(value.contextWindow) || value.contextWindow <= 0) {
|
||||
errors.push(`${label} has invalid contextWindow`);
|
||||
}
|
||||
if (typeof value.maxTokens !== "number" || !Number.isFinite(value.maxTokens) || value.maxTokens <= 0) {
|
||||
errors.push(`${label} has invalid maxTokens`);
|
||||
}
|
||||
if (!isRecord(value.cost)) {
|
||||
errors.push(`${label} has invalid cost metadata`);
|
||||
} else {
|
||||
for (const field of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
||||
const cost = value.cost[field];
|
||||
if (typeof cost !== "number" || !Number.isFinite(cost)) {
|
||||
errors.push(`${label} has invalid cost.${field}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function throwValidationErrors(errors: string[]): never {
|
||||
const visible = errors.slice(0, 30);
|
||||
const suffix = errors.length > visible.length ? `\n ... and ${errors.length - visible.length} more` : "";
|
||||
throw new Error(`Invalid generated model data:\n${visible.map((error) => ` - ${error}`).join("\n")}${suffix}`);
|
||||
}
|
||||
|
||||
export function validateModelDataDirectory(structure: ModelDataStructure, dataDir: string): void {
|
||||
if (!existsSync(dataDir) || !statSync(dataDir).isDirectory()) {
|
||||
throw new Error(`Generated model data directory does not exist: ${dataDir}`);
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const expectedFiles = Object.keys(structure)
|
||||
.map((providerId) => `${providerId}.json`)
|
||||
.sort();
|
||||
const actualFiles = readdirSync(dataDir)
|
||||
.filter((entry) => entry.endsWith(".json") && entry !== MODEL_DATA_MANIFEST_FILE)
|
||||
.sort();
|
||||
if (!sameStrings(expectedFiles, actualFiles)) {
|
||||
errors.push(`provider data files do not match the generated catalog (${describeSetDifference(expectedFiles, actualFiles)})`);
|
||||
}
|
||||
|
||||
const manifestPath = join(dataDir, MODEL_DATA_MANIFEST_FILE);
|
||||
const manifest = readJsonObject(manifestPath, "model data manifest", errors);
|
||||
if (manifest?.schemaVersion !== MODEL_DATA_SCHEMA_VERSION) {
|
||||
errors.push(
|
||||
`model data schema is ${JSON.stringify(manifest?.schemaVersion)}, expected ${MODEL_DATA_SCHEMA_VERSION}`,
|
||||
);
|
||||
}
|
||||
if (typeof manifest?.generatedAt !== "string" || Number.isNaN(Date.parse(manifest.generatedAt))) {
|
||||
errors.push("model data manifest has an invalid generation timestamp");
|
||||
}
|
||||
const expectedStructureHash = modelDataStructureHash(structure);
|
||||
if (manifest?.structureHash !== expectedStructureHash) {
|
||||
errors.push("model data generation stamp does not match the generated catalog");
|
||||
}
|
||||
const manifestFiles = isRecord(manifest?.files) ? manifest.files : undefined;
|
||||
if (!manifestFiles) errors.push("model data manifest has no file hashes");
|
||||
else {
|
||||
const manifestFileNames = Object.keys(manifestFiles).sort();
|
||||
if (!sameStrings(expectedFiles, manifestFileNames)) {
|
||||
errors.push(`manifest file hashes do not match provider data files (${describeSetDifference(expectedFiles, manifestFileNames)})`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [providerId, expectedModels] of Object.entries(structure)) {
|
||||
const filename = `${providerId}.json`;
|
||||
const path = join(dataDir, filename);
|
||||
if (!existsSync(path)) continue;
|
||||
const content = readFileSync(path, "utf8");
|
||||
if (manifestFiles && manifestFiles[filename] !== sha256(content)) {
|
||||
errors.push(`${filename} does not match its manifest hash`);
|
||||
}
|
||||
const groups = readJsonObject(path, filename, errors);
|
||||
if (!groups) continue;
|
||||
|
||||
const actualModels = new Map<string, string>();
|
||||
for (const [api, value] of Object.entries(groups)) {
|
||||
if (!isRecord(value)) {
|
||||
errors.push(`${filename} API group ${JSON.stringify(api)} must be an object`);
|
||||
continue;
|
||||
}
|
||||
for (const [modelId, model] of Object.entries(value)) {
|
||||
if (actualModels.has(modelId)) {
|
||||
errors.push(`${providerId}/${modelId} appears in more than one API group`);
|
||||
continue;
|
||||
}
|
||||
actualModels.set(modelId, api);
|
||||
validateModelValue(model, providerId, modelId, api, errors);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedModelIds = Object.keys(expectedModels).sort();
|
||||
const actualModelIds = Array.from(actualModels.keys()).sort();
|
||||
if (!sameStrings(expectedModelIds, actualModelIds)) {
|
||||
errors.push(`${filename} model IDs do not match the generated catalog (${describeSetDifference(expectedModelIds, actualModelIds)})`);
|
||||
}
|
||||
for (const [modelId, expectedApi] of Object.entries(expectedModels)) {
|
||||
const actualApi = actualModels.get(modelId);
|
||||
if (actualApi !== undefined && actualApi !== expectedApi) {
|
||||
errors.push(
|
||||
`${providerId}/${modelId} is grouped under API ${JSON.stringify(actualApi)}, expected ${JSON.stringify(expectedApi)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) throwValidationErrors(errors);
|
||||
}
|
||||
|
||||
export function validateGeneratedModelData(packageRoot: string): void {
|
||||
const structure = readModelDataStructure(packageRoot);
|
||||
validateModelDataDirectory(structure, join(packageRoot, "src", "providers", "data"));
|
||||
}
|
||||
30
packages/ai/scripts/models-dev-reasoning-options.ts
Normal file
30
packages/ai/scripts/models-dev-reasoning-options.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
|
||||
|
||||
export type ModelsDevReasoningOption =
|
||||
| { type: "toggle" }
|
||||
| {
|
||||
type: "effort";
|
||||
values: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "default" | null>;
|
||||
}
|
||||
| { type: "budget_tokens"; min?: number; max?: number };
|
||||
|
||||
const THINKING_LEVELS: readonly ThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
/**
|
||||
* Converts models.dev verified effort values into Pi's selectable thinking levels.
|
||||
* Values without a Pi equivalent (`default` and JSON `null`) are intentionally
|
||||
* omitted.
|
||||
*/
|
||||
export function getEffortThinkingLevelMap(options: readonly ModelsDevReasoningOption[]): ThinkingLevelMap | undefined {
|
||||
const effortValues = options.flatMap((option) => (option.type === "effort" ? option.values : []));
|
||||
if (effortValues.length === 0) return undefined;
|
||||
|
||||
const supported = new Set(effortValues);
|
||||
if (!THINKING_LEVELS.some((level) => supported.has(level)) && !supported.has("none")) return undefined;
|
||||
|
||||
const map: ThinkingLevelMap = { off: supported.has("none") ? "none" : null };
|
||||
for (const level of THINKING_LEVELS) {
|
||||
map[level] = supported.has(level) ? level : null;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
23
packages/ai/scripts/openrouter-reasoning-options.ts
Normal file
23
packages/ai/scripts/openrouter-reasoning-options.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ThinkingLevel, ThinkingLevelMap } from "../src/types.ts";
|
||||
import { getEffortThinkingLevelMap } from "./models-dev-reasoning-options.ts";
|
||||
|
||||
export interface OpenRouterReasoningMetadata {
|
||||
mandatory?: boolean;
|
||||
default_enabled?: boolean;
|
||||
supported_efforts?: Array<ThinkingLevel | "none">;
|
||||
default_effort?: ThinkingLevel | "none";
|
||||
}
|
||||
|
||||
/** Convert OpenRouter's reasoning metadata into Pi model capabilities. */
|
||||
export function getOpenRouterThinkingLevelMap(
|
||||
reasoning: OpenRouterReasoningMetadata | undefined,
|
||||
): ThinkingLevelMap | undefined {
|
||||
if (!reasoning) return undefined;
|
||||
if (!reasoning.supported_efforts?.length) return reasoning.mandatory === true ? { off: null } : undefined;
|
||||
|
||||
// OpenRouter's supported_efforts uses the same effort values as models.dev reasoning_options,
|
||||
// so both sources can share the same Pi thinking-level conversion.
|
||||
const map = getEffortThinkingLevelMap([{ type: "effort", values: reasoning.supported_efforts }]);
|
||||
if (!map) return reasoning.mandatory === true ? { off: null } : undefined;
|
||||
return { ...map, off: reasoning.mandatory === true ? null : "none" };
|
||||
}
|
||||
4
packages/ai/src/api/anthropic-messages.lazy.ts
Normal file
4
packages/ai/src/api/anthropic-messages.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const anthropicMessagesApi = (): ProviderStreams => lazyApi(() => import("./anthropic-messages.ts"));
|
||||
1489
packages/ai/src/api/anthropic-messages.ts
Normal file
1489
packages/ai/src/api/anthropic-messages.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
packages/ai/src/api/azure-openai-responses.lazy.ts
Normal file
4
packages/ai/src/api/azure-openai-responses.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const azureOpenAIResponsesApi = (): ProviderStreams => lazyApi(() => import("./azure-openai-responses.ts"));
|
||||
338
packages/ai/src/api/azure-openai-responses.ts
Normal file
338
packages/ai/src/api/azure-openai-responses.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { AzureOpenAI } from "openai";
|
||||
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
|
||||
import { clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
||||
const DEFAULT_AZURE_API_VERSION = "v1";
|
||||
const AZURE_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode", "azure-openai-responses"]);
|
||||
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
|
||||
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
|
||||
|
||||
function parseDeploymentNameMap(value: string | undefined): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!value) return map;
|
||||
for (const entry of value.split(",")) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
const [modelId, deploymentName] = trimmed.split("=", 2);
|
||||
if (!modelId || !deploymentName) continue;
|
||||
map.set(modelId.trim(), deploymentName.trim());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: AzureOpenAIResponsesOptions): string {
|
||||
if (options?.azureDeploymentName) {
|
||||
return options.azureDeploymentName;
|
||||
}
|
||||
const mappedDeployment = parseDeploymentNameMap(
|
||||
getProviderEnvValue("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", options?.env),
|
||||
).get(model.id);
|
||||
return mappedDeployment || model.id;
|
||||
}
|
||||
|
||||
function formatAzureOpenAIError(error: unknown): string {
|
||||
return formatProviderError(normalizeProviderError(error), "Azure OpenAI API error");
|
||||
}
|
||||
|
||||
// Azure OpenAI Responses-specific options
|
||||
export interface AzureOpenAIResponsesOptions extends StreamOptions {
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
toolChoice?: ResponseCreateParamsStreaming["tool_choice"];
|
||||
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
||||
azureApiVersion?: string;
|
||||
azureResourceName?: string;
|
||||
azureBaseUrl?: string;
|
||||
azureDeploymentName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate function for Azure OpenAI Responses API
|
||||
*/
|
||||
export const stream: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions> = (
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
options?: AzureOpenAIResponsesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
// Start async processing
|
||||
(async () => {
|
||||
const deploymentName = resolveDeploymentName(model, options);
|
||||
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "azure-openai-responses" as Api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Create Azure OpenAI client
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
);
|
||||
let params = buildParams(model, context, options, deploymentName, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
}
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: openaiStream, response } = await retryProviderRequest(
|
||||
() => client.responses.create(params, requestOptions).withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(openaiStream, output, stream, model, { grammarToolInputProperties });
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (output.stopReason === "pending") {
|
||||
throw new Error("Azure OpenAI Responses stream ended without a stop reason");
|
||||
}
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage || "An unknown error occurred");
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
delete (block as { index?: number }).index;
|
||||
// Streaming scratch buffers are only used during parsing; never persist them.
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatAzureOpenAIError(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
export const streamSimple: StreamFunction<"azure-openai-responses", SimpleStreamOptions> = (
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const base = {
|
||||
...buildBaseOptions(model, context, options, apiKey),
|
||||
toolChoice: options?.toolChoice,
|
||||
} satisfies AzureOpenAIResponsesOptions;
|
||||
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
|
||||
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
|
||||
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
reasoningEffort,
|
||||
} satisfies AzureOpenAIResponsesOptions);
|
||||
};
|
||||
|
||||
function normalizeAzureBaseUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed);
|
||||
} catch {
|
||||
throw new Error(`Invalid Azure OpenAI base URL: ${baseUrl}`);
|
||||
}
|
||||
|
||||
const isAzureHost =
|
||||
url.hostname.endsWith(".openai.azure.com") ||
|
||||
url.hostname.endsWith(".cognitiveservices.azure.com") ||
|
||||
url.hostname.endsWith(".ai.azure.com");
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, "");
|
||||
|
||||
// Ensure Azure hosts have /openai/v1 as base path so the AzureOpenAI SDK
|
||||
// can append /deployments/<model>/... and ?api-version=v1 correctly.
|
||||
if (
|
||||
isAzureHost &&
|
||||
(normalizedPath === "" ||
|
||||
normalizedPath === "/" ||
|
||||
normalizedPath === "/openai" ||
|
||||
normalizedPath === "/openai/v1/responses")
|
||||
) {
|
||||
url.pathname = "/openai/v1";
|
||||
url.search = "";
|
||||
}
|
||||
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function buildDefaultBaseUrl(resourceName: string): string {
|
||||
return `https://${resourceName}.openai.azure.com/openai/v1`;
|
||||
}
|
||||
|
||||
function resolveAzureConfig(
|
||||
model: Model<"azure-openai-responses">,
|
||||
options?: AzureOpenAIResponsesOptions,
|
||||
): { baseUrl: string; apiVersion: string } {
|
||||
const apiVersion =
|
||||
options?.azureApiVersion ||
|
||||
getProviderEnvValue("AZURE_OPENAI_API_VERSION", options?.env) ||
|
||||
DEFAULT_AZURE_API_VERSION;
|
||||
|
||||
const baseUrl =
|
||||
options?.azureBaseUrl?.trim() || getProviderEnvValue("AZURE_OPENAI_BASE_URL", options?.env)?.trim() || undefined;
|
||||
const resourceName = options?.azureResourceName || getProviderEnvValue("AZURE_OPENAI_RESOURCE_NAME", options?.env);
|
||||
|
||||
let resolvedBaseUrl = baseUrl;
|
||||
|
||||
if (!resolvedBaseUrl && resourceName) {
|
||||
resolvedBaseUrl = buildDefaultBaseUrl(resourceName);
|
||||
}
|
||||
|
||||
if (!resolvedBaseUrl && model.baseUrl) {
|
||||
resolvedBaseUrl = model.baseUrl;
|
||||
}
|
||||
|
||||
if (!resolvedBaseUrl) {
|
||||
throw new Error(
|
||||
"Azure OpenAI base URL is required. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME, or pass azureBaseUrl, azureResourceName, or model.baseUrl.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: normalizeAzureBaseUrl(resolvedBaseUrl),
|
||||
apiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
function createClient(model: Model<"azure-openai-responses">, apiKey: string, options?: AzureOpenAIResponsesOptions) {
|
||||
const headers = { "User-Agent": getPiUserAgent(), ...model.headers };
|
||||
|
||||
if (options?.headers) {
|
||||
Object.assign(headers, options.headers);
|
||||
}
|
||||
|
||||
const { baseUrl, apiVersion } = resolveAzureConfig(model, options);
|
||||
|
||||
return new AzureOpenAI({
|
||||
apiKey,
|
||||
apiVersion,
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetch: options?.fetch,
|
||||
defaultHeaders: headers,
|
||||
baseURL: baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"azure-openai-responses">,
|
||||
context: Context,
|
||||
options: AzureOpenAIResponsesOptions | undefined,
|
||||
deploymentName: string,
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
),
|
||||
) {
|
||||
const messages = convertResponsesMessages(model, context, AZURE_TOOL_CALL_PROVIDERS, {
|
||||
grammarToolInputProperties,
|
||||
});
|
||||
|
||||
const params: ResponseCreateParamsStreaming = {
|
||||
model: deploymentName,
|
||||
input: messages,
|
||||
stream: true,
|
||||
prompt_cache_key: clampOpenAIPromptCacheKey(options?.sessionId),
|
||||
store: false,
|
||||
};
|
||||
|
||||
if (options?.maxTokens) {
|
||||
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
|
||||
}
|
||||
|
||||
if (options?.temperature !== undefined) {
|
||||
params.temperature = options?.temperature;
|
||||
}
|
||||
|
||||
if (context.tools && context.tools.length > 0) {
|
||||
params.tools = convertResponsesTools(context.tools, {
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? true,
|
||||
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
});
|
||||
}
|
||||
if (options?.toolChoice !== undefined) {
|
||||
params.tool_choice = options.toolChoice;
|
||||
}
|
||||
|
||||
if (model.reasoning) {
|
||||
if (options?.reasoningEffort || options?.reasoningSummary) {
|
||||
const effort = options?.reasoningEffort
|
||||
? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)
|
||||
: "medium";
|
||||
params.reasoning = {
|
||||
effort: effort as NonNullable<typeof params.reasoning>["effort"],
|
||||
summary: options?.reasoningSummary || "auto",
|
||||
};
|
||||
params.include = ["reasoning.encrypted_content"];
|
||||
} else if (model.thinkingLevelMap?.off !== null) {
|
||||
params.reasoning = {
|
||||
effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Last so custom keys override the named request fields.
|
||||
if (options?.samplingParams) {
|
||||
Object.assign(params, options.samplingParams);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
30
packages/ai/src/api/bedrock-converse-stream.lazy.ts
Normal file
30
packages/ai/src/api/bedrock-converse-stream.lazy.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
/**
|
||||
* Loads the bedrock implementation through a variable specifier so bundlers
|
||||
* (browser smoke, Bun compile) cannot follow the import into the Node-only
|
||||
* AWS SDK. The `.ts`/`.js` rewrite keeps the trick working from both source
|
||||
* and built output.
|
||||
*/
|
||||
const importNodeOnlyApi = (specifier: string): Promise<unknown> => {
|
||||
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
|
||||
return import(runtimeSpecifier);
|
||||
};
|
||||
|
||||
let bedrockModuleOverride: ProviderStreams | undefined;
|
||||
|
||||
/**
|
||||
* Overrides the dynamically imported bedrock implementation. Used by the Bun
|
||||
* binary build, where the variable-specifier import cannot be bundled; the
|
||||
* build registers a statically imported module instead.
|
||||
*/
|
||||
export function setBedrockProviderModule(module: ProviderStreams): void {
|
||||
bedrockModuleOverride = module;
|
||||
}
|
||||
|
||||
export const bedrockConverseStreamApi = (): ProviderStreams =>
|
||||
lazyApi(
|
||||
async () =>
|
||||
bedrockModuleOverride ?? ((await importNodeOnlyApi("./bedrock-converse-stream.ts")) as ProviderStreams),
|
||||
);
|
||||
1325
packages/ai/src/api/bedrock-converse-stream.ts
Normal file
1325
packages/ai/src/api/bedrock-converse-stream.ts
Normal file
File diff suppressed because it is too large
Load Diff
90
packages/ai/src/api/cloudflare-ai-binding.ts
Normal file
90
packages/ai/src/api/cloudflare-ai-binding.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* AI Gateway transport over the Workers AI binding.
|
||||
*
|
||||
* pi's Cloudflare AI Gateway support speaks HTTPS
|
||||
* (`gateway.ai.cloudflare.com/v1/{account}/{gateway}/{provider}/...`, see `api/cloudflare.ts`),
|
||||
* which needs a Cloudflare API token even when the caller is a Worker in the gateway's own
|
||||
* account.
|
||||
*
|
||||
* A Worker avoids that token by talking to the gateway through the AI binding's `fetch`
|
||||
* passthrough (`env.AI.fetch()`), which serves the gateway's provider passthrough at
|
||||
* `https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/{endpoint...}` — the same
|
||||
* shape as the HTTPS URL, minus the account id (the binding channel carries identity). Binding
|
||||
* calls are pre-authenticated in-account and return the provider's native wire format as a
|
||||
* regular (streaming) `Response`, so API implementations behave identically over either
|
||||
* transport.
|
||||
*
|
||||
* A model whose `baseUrl` names that route therefore needs no translation: point it there and
|
||||
* pass {@link createAiBindingFetch} as the request `fetch`. Nothing is rewritten, buffered or
|
||||
* re-encoded — method, headers, query string and the body stream go to the binding as they
|
||||
* arrive, so every method, non-JSON body and streaming request body works.
|
||||
*
|
||||
* The only thing this module adds over calling `env.AI.fetch()` yourself is a type: `Ai#fetch`
|
||||
* exists at runtime (`workerd/src/cloudflare/internal/ai-api.ts:158`) but
|
||||
* `@cloudflare/workers-types`' `Ai` class does not declare it yet, so calling it directly means
|
||||
* casting the binding at every call site. {@link AiBinding} takes the cast instead — declare
|
||||
* `fetch` optional, check it once at construction — so `env.AI` can be passed as-is. Once
|
||||
* workers-types declares `fetch`, the optional marker and the runtime check both go away.
|
||||
*/
|
||||
|
||||
import type { FetchFunction } from "../types.ts";
|
||||
|
||||
/**
|
||||
* The Workers AI binding (`env.AI`), described structurally so this module does not depend on
|
||||
* `@cloudflare/workers-types`.
|
||||
*
|
||||
* `fetch` is optional only because the published `Ai` type doesn't declare it yet — every real
|
||||
* binding has it at runtime. `aiGatewayLogId` is here to pin the type to the AI binding: it is
|
||||
* the one member unique to `Ai`, so without it this interface would also accept an `AiGateway`
|
||||
* or any hand-rolled `{ fetch }` object, which is exactly the mistake the runtime check reports
|
||||
* late.
|
||||
*/
|
||||
export interface AiBinding {
|
||||
aiGatewayLogId: string | null;
|
||||
fetch?(input: Request | string | URL, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder value for auth headers on binding-routed requests. API implementations
|
||||
* require an API key or a recognized auth header (`authorization`, `x-api-key`,
|
||||
* `cf-aig-authorization`) before dispatch; binding calls are pre-authenticated, so pass
|
||||
* `cf-aig-authorization: Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}` to satisfy
|
||||
* the check. The gateway ignores (and strips) `cf-aig-authorization` on binding-routed
|
||||
* requests. Pair it with `Authorization: null` / `x-api-key: null` so the SDKs' placeholder
|
||||
* auth headers never reach the gateway, which would treat a request-supplied auth header as a
|
||||
* BYOK provider key that overrides its stored keys — the same as it would over HTTPS.
|
||||
*/
|
||||
export const CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL = "cloudflare-gateway-binding";
|
||||
|
||||
/**
|
||||
* Create a `fetch` backed by the AI binding, for models whose `baseUrl` already names a route
|
||||
* the binding serves — including the gateway's provider passthrough,
|
||||
* `https://workers-binding.ai/ai-gateway/gateways/{gateway}/{provider}/...`. Requests pass
|
||||
* through untouched.
|
||||
*
|
||||
* ```ts
|
||||
* const model = {
|
||||
* // ...
|
||||
* baseUrl: `https://workers-binding.ai/ai-gateway/gateways/${gateway}/anthropic`,
|
||||
* };
|
||||
* await models.complete(model, context, {
|
||||
* headers: {
|
||||
* "cf-aig-authorization": `Bearer ${CLOUDFLARE_GATEWAY_BINDING_AUTH_SENTINEL}`,
|
||||
* Authorization: null,
|
||||
* "x-api-key": null,
|
||||
* },
|
||||
* fetch: createAiBindingFetch(env.AI),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createAiBindingFetch(binding: AiBinding): FetchFunction {
|
||||
// `fetch` is optional on the type, so its presence is checked here — early, rather than as a
|
||||
// confusing failure on the first inference request.
|
||||
if (typeof binding.fetch !== "function") {
|
||||
throw new TypeError("createAiBindingFetch: the AI binding does not expose fetch()");
|
||||
}
|
||||
// Bound eagerly: `fetch` is a mutable property, so the narrowing above would not survive into
|
||||
// the returned closure.
|
||||
const bindingFetch = binding.fetch.bind(binding);
|
||||
return (input, init) => bindingFetch(input, init);
|
||||
}
|
||||
15
packages/ai/src/api/cloudflare.ts
Normal file
15
packages/ai/src/api/cloudflare.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Workers AI direct endpoint. */
|
||||
export const CLOUDFLARE_WORKERS_AI_BASE_URL =
|
||||
"https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1";
|
||||
|
||||
/** AI Gateway Unified API. https://developers.cloudflare.com/ai-gateway/usage/unified-api/ */
|
||||
export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL =
|
||||
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat";
|
||||
|
||||
/** AI Gateway → OpenAI passthrough. Used until /compat supports /v1/responses. */
|
||||
export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL =
|
||||
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai";
|
||||
|
||||
/** AI Gateway → Anthropic passthrough. */
|
||||
export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL =
|
||||
"https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic";
|
||||
277
packages/ai/src/api/constrained-sampling.ts
Normal file
277
packages/ai/src/api/constrained-sampling.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { Tool } from "../types.ts";
|
||||
|
||||
interface JsonSchemaObject {
|
||||
[key: string]: unknown;
|
||||
type?: unknown;
|
||||
properties?: Record<string, JsonSchemaObject | undefined>;
|
||||
required?: unknown;
|
||||
}
|
||||
|
||||
class UnsupportedStrictJsonSchemaError extends Error {}
|
||||
|
||||
const UNSUPPORTED_STRICT_SCHEMA_KEYS = [
|
||||
"$ref",
|
||||
"$defs",
|
||||
"definitions",
|
||||
"allOf",
|
||||
"oneOf",
|
||||
"patternProperties",
|
||||
"dependentSchemas",
|
||||
"dependencies",
|
||||
"unevaluatedProperties",
|
||||
"propertyNames",
|
||||
"contains",
|
||||
"prefixItems",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
] as const;
|
||||
|
||||
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStructuredSchema(schema: unknown): boolean {
|
||||
if (!isJsonSchemaObject(schema)) return false;
|
||||
const types = typeof schema.type === "string" ? [schema.type] : Array.isArray(schema.type) ? schema.type : [];
|
||||
return (
|
||||
types.includes("object") ||
|
||||
types.includes("array") ||
|
||||
schema.properties !== undefined ||
|
||||
schema.items !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function schemaAllowsNull(schema: unknown): boolean {
|
||||
if (!isJsonSchemaObject(schema)) return false;
|
||||
if (schema.type === "null" || (Array.isArray(schema.type) && schema.type.includes("null"))) return true;
|
||||
if (schema.const === null || (Array.isArray(schema.enum) && schema.enum.includes(null))) return true;
|
||||
return Array.isArray(schema.anyOf) && schema.anyOf.some((variant) => schemaAllowsNull(variant));
|
||||
}
|
||||
|
||||
function makeJsonSchemaNodeStrict(schema: unknown): void {
|
||||
if (!isJsonSchemaObject(schema)) {
|
||||
throw new UnsupportedStrictJsonSchemaError("boolean schemas are unsupported");
|
||||
}
|
||||
for (const key of UNSUPPORTED_STRICT_SCHEMA_KEYS) {
|
||||
if (schema[key] !== undefined) {
|
||||
throw new UnsupportedStrictJsonSchemaError(`${key} schemas are unsupported`);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.anyOf !== undefined) {
|
||||
if (!Array.isArray(schema.anyOf) || schema.anyOf.length === 0) {
|
||||
throw new UnsupportedStrictJsonSchemaError("anyOf must contain at least one schema");
|
||||
}
|
||||
for (const variant of schema.anyOf) {
|
||||
if (isStructuredSchema(variant)) {
|
||||
throw new UnsupportedStrictJsonSchemaError("object and array unions are unsupported");
|
||||
}
|
||||
makeJsonSchemaNodeStrict(variant);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.items !== undefined) {
|
||||
if (Array.isArray(schema.items)) {
|
||||
throw new UnsupportedStrictJsonSchemaError("tuple schemas are unsupported");
|
||||
}
|
||||
makeJsonSchemaNodeStrict(schema.items);
|
||||
}
|
||||
|
||||
const isObjectSchema = schema.type === "object";
|
||||
if (schema.properties !== undefined && !isObjectSchema) {
|
||||
throw new UnsupportedStrictJsonSchemaError("properties require type object");
|
||||
}
|
||||
if (!isObjectSchema) return;
|
||||
if (schema.additionalProperties !== undefined && schema.additionalProperties !== false) {
|
||||
throw new UnsupportedStrictJsonSchemaError("schema-valued or true additionalProperties is unsupported");
|
||||
}
|
||||
if (schema.properties !== undefined && !isJsonSchemaObject(schema.properties)) {
|
||||
throw new UnsupportedStrictJsonSchemaError("object properties must be a schema map");
|
||||
}
|
||||
if (
|
||||
schema.required !== undefined &&
|
||||
(!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string"))
|
||||
) {
|
||||
throw new UnsupportedStrictJsonSchemaError("object required must be a string array");
|
||||
}
|
||||
|
||||
const properties = schema.properties ?? {};
|
||||
const propertyNames = Object.keys(properties);
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
if ([...required].some((key) => !propertyNames.includes(key))) {
|
||||
throw new UnsupportedStrictJsonSchemaError("required contains an unknown property");
|
||||
}
|
||||
for (const [key, property] of Object.entries(properties)) {
|
||||
makeJsonSchemaNodeStrict(property);
|
||||
if (!required.has(key) && !schemaAllowsNull(property)) {
|
||||
properties[key] = { anyOf: [property, { type: "null" }] };
|
||||
}
|
||||
}
|
||||
schema.required = propertyNames;
|
||||
schema.additionalProperties = false;
|
||||
}
|
||||
|
||||
/** Convert a tool schema to the strict subset expected by provider constrained sampling. */
|
||||
export function makeStrictJsonSchema(schema: Tool["parameters"]): Record<string, unknown> {
|
||||
const cloned: unknown = structuredClone(schema);
|
||||
if (!isJsonSchemaObject(cloned)) {
|
||||
throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
|
||||
}
|
||||
makeJsonSchemaNodeStrict(cloned);
|
||||
if (cloned.type !== "object") {
|
||||
throw new UnsupportedStrictJsonSchemaError("root schema must have type object");
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
export function getJsonSchemaToolParameters(tool: Tool, strict: boolean | undefined): Tool["parameters"] {
|
||||
return (strict === true ? makeStrictJsonSchema(tool.parameters) : tool.parameters) as Tool["parameters"];
|
||||
}
|
||||
|
||||
export interface GrammarConstrainedSampling {
|
||||
format: "lark" | "regex";
|
||||
definition: string;
|
||||
inputProperty: string;
|
||||
}
|
||||
|
||||
export interface GrammarToolInputJsonBuffer {
|
||||
input: string;
|
||||
started: boolean;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export function getGrammarToolInput(
|
||||
toolName: string,
|
||||
arguments_: Record<string, unknown>,
|
||||
inputProperty: string,
|
||||
): string {
|
||||
const input = arguments_[inputProperty];
|
||||
if (typeof input !== "string") {
|
||||
throw new Error(`Grammar tool call "${toolName}" requires argument "${inputProperty}" to be a string.`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function appendGrammarToolInputJsonDelta(
|
||||
buffer: GrammarToolInputJsonBuffer,
|
||||
inputProperty: string,
|
||||
nextInput: string,
|
||||
close: boolean,
|
||||
): string | undefined {
|
||||
if (buffer.closed) {
|
||||
if (close && nextInput === buffer.input) return undefined;
|
||||
throw new Error(`grammar tool input for property "${inputProperty}" changed after it was closed`);
|
||||
}
|
||||
if (!nextInput.startsWith(buffer.input)) {
|
||||
throw new Error(`grammar tool input for property "${inputProperty}" changed non-monotonically`);
|
||||
}
|
||||
|
||||
const inputDelta = nextInput.slice(buffer.input.length);
|
||||
if (!close && inputDelta.length === 0) return undefined;
|
||||
|
||||
let delta = "";
|
||||
if (!buffer.started) {
|
||||
delta += `{${JSON.stringify(inputProperty)}:"`;
|
||||
buffer.started = true;
|
||||
}
|
||||
delta += JSON.stringify(inputDelta).slice(1, -1);
|
||||
buffer.input = nextInput;
|
||||
|
||||
if (close) {
|
||||
delta += '"}';
|
||||
buffer.closed = true;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
function inferGrammarInputProperty(tool: Tool): string {
|
||||
const schema = tool.parameters as JsonSchemaObject;
|
||||
if (schema.type !== "object") {
|
||||
throw new Error("grammar constrained sampling requires an object parameter schema");
|
||||
}
|
||||
if (!Array.isArray(schema.required) || schema.required.length !== 1 || typeof schema.required[0] !== "string") {
|
||||
throw new Error("grammar constrained sampling requires exactly one required string property");
|
||||
}
|
||||
|
||||
const inputProperty = schema.required[0];
|
||||
if (!schema.properties?.[inputProperty]) {
|
||||
throw new Error(`grammar constrained sampling requires a properties entry for ${inputProperty}`);
|
||||
}
|
||||
if (schema.properties[inputProperty]?.type !== "string") {
|
||||
throw new Error(`grammar constrained sampling property ${inputProperty} must have type string`);
|
||||
}
|
||||
return inputProperty;
|
||||
}
|
||||
|
||||
export function resolveJsonSchemaStrictSampling(tool: Tool, supportsStrictMode: boolean): boolean | undefined {
|
||||
const config = tool.constrainedSampling;
|
||||
if (!config || config.type !== "json_schema") return undefined;
|
||||
|
||||
if (supportsStrictMode) {
|
||||
try {
|
||||
makeStrictJsonSchema(tool.parameters);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof UnsupportedStrictJsonSchemaError)) throw error;
|
||||
if (config.strict !== "require") return undefined;
|
||||
throw new Error(`Tool "${tool.name}" requires JSON-schema constrained sampling, but ${error.message}.`);
|
||||
}
|
||||
}
|
||||
if (config.strict === "require") {
|
||||
throw new Error(
|
||||
`Tool "${tool.name}" requires JSON-schema constrained sampling, but strict tools are unsupported.`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveGrammarConstrainedSampling(
|
||||
tool: Tool,
|
||||
supportsOpenAIGrammarTools: boolean,
|
||||
): GrammarConstrainedSampling | undefined {
|
||||
const config = tool.constrainedSampling;
|
||||
if (!config || config.type !== "grammar") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!supportsOpenAIGrammarTools) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const larkDefinition = config.variants.openai_lark;
|
||||
const regexDefinition = config.variants.openai_regex;
|
||||
const hasLarkDefinition = typeof larkDefinition === "string" && larkDefinition.trim().length > 0;
|
||||
const hasRegexDefinition = typeof regexDefinition === "string" && regexDefinition.trim().length > 0;
|
||||
if (!hasLarkDefinition && !hasRegexDefinition) {
|
||||
throw new Error(
|
||||
`Tool "${tool.name}" cannot use grammar constrained sampling: no supported grammar variant was provided.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
format: hasLarkDefinition ? "lark" : "regex",
|
||||
definition: hasLarkDefinition ? larkDefinition : regexDefinition!,
|
||||
inputProperty: inferGrammarInputProperty(tool),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Tool "${tool.name}" cannot use grammar constrained sampling: ${message}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGrammarToolInputProperties(
|
||||
tools: Tool[] | undefined,
|
||||
supportsOpenAIGrammarTools: boolean,
|
||||
): ReadonlyMap<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
for (const tool of tools ?? []) {
|
||||
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
|
||||
if (grammar) {
|
||||
properties.set(tool.name, grammar.inputProperty);
|
||||
}
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
37
packages/ai/src/api/github-copilot-headers.ts
Normal file
37
packages/ai/src/api/github-copilot-headers.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Message } from "../types.ts";
|
||||
|
||||
// Copilot expects X-Initiator to indicate whether the request is user-initiated
|
||||
// or agent-initiated (e.g. follow-up after assistant/tool messages).
|
||||
export function inferCopilotInitiator(messages: Message[]): "user" | "agent" {
|
||||
const last = messages[messages.length - 1];
|
||||
return last && last.role !== "user" ? "agent" : "user";
|
||||
}
|
||||
|
||||
// Copilot requires Copilot-Vision-Request header when sending images
|
||||
export function hasCopilotVisionInput(messages: Message[]): boolean {
|
||||
return messages.some((msg) => {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
return msg.content.some((c) => c.type === "image");
|
||||
}
|
||||
if (msg.role === "toolResult" && Array.isArray(msg.content)) {
|
||||
return msg.content.some((c) => c.type === "image");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCopilotDynamicHeaders(params: {
|
||||
messages: Message[];
|
||||
hasImages: boolean;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Initiator": inferCopilotInitiator(params.messages),
|
||||
"Openai-Intent": "conversation-edits",
|
||||
};
|
||||
|
||||
if (params.hasImages) {
|
||||
headers["Copilot-Vision-Request"] = "true";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
4
packages/ai/src/api/google-generative-ai.lazy.ts
Normal file
4
packages/ai/src/api/google-generative-ai.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const googleGenerativeAIApi = (): ProviderStreams => lazyApi(() => import("./google-generative-ai.ts"));
|
||||
526
packages/ai/src/api/google-generative-ai.ts
Normal file
526
packages/ai/src/api/google-generative-ai.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
type GenerateContentConfig,
|
||||
type GenerateContentParameters,
|
||||
GoogleGenAI,
|
||||
type ThinkingConfig,
|
||||
} from "@google/genai";
|
||||
import { calculateCost, clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
ProviderHeaders,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TextContent,
|
||||
ThinkingBudgets,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
|
||||
import {
|
||||
convertMessages,
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReason,
|
||||
resolveGoogleFunctionCallingMode,
|
||||
resolveGoogleThinkingLevel,
|
||||
retainThoughtSignature,
|
||||
retryGoogleRequest,
|
||||
supportsGoogleStrictToolSampling,
|
||||
} from "./google-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
||||
export interface GoogleOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any";
|
||||
thinking?: {
|
||||
enabled: boolean;
|
||||
budgetTokens?: number; // -1 for dynamic, 0 to disable
|
||||
level?: GoogleApiThinkingLevel;
|
||||
};
|
||||
}
|
||||
|
||||
// Counter for generating unique tool call IDs
|
||||
let toolCallCounter = 0;
|
||||
|
||||
export const stream: StreamFunction<"google-generative-ai", GoogleOptions> = (
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
options?: GoogleOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "google-generative-ai" as Api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.fetch && options.fetch !== globalThis.fetch) {
|
||||
throw new Error("Custom fetch is not supported by the Google Generative AI adapter");
|
||||
}
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options?.headers);
|
||||
let params = buildParams(model, context, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as GenerateContentParameters;
|
||||
}
|
||||
const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options);
|
||||
|
||||
stream.push({ type: "start", partial: output });
|
||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||
const blocks = output.content;
|
||||
const blockIndex = () => blocks.length - 1;
|
||||
for await (const chunk of googleStream) {
|
||||
// @google/genai documents GenerateContentResponse.responseId as an output-only field
|
||||
// used to identify each response. Keep the first non-empty one from the stream.
|
||||
output.responseId ||= chunk.responseId;
|
||||
const candidate = chunk.candidates?.[0];
|
||||
if (candidate?.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text !== undefined) {
|
||||
const isThinking = isThinkingPart(part);
|
||||
if (
|
||||
!currentBlock ||
|
||||
(isThinking && currentBlock.type !== "thinking") ||
|
||||
(!isThinking && currentBlock.type !== "text")
|
||||
) {
|
||||
if (currentBlock) {
|
||||
if (currentBlock.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blocks.length - 1,
|
||||
content: currentBlock.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isThinking) {
|
||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
||||
} else {
|
||||
currentBlock = { type: "text", text: "" };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
||||
}
|
||||
}
|
||||
if (currentBlock.type === "thinking") {
|
||||
currentBlock.thinking += part.text;
|
||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||
currentBlock.thinkingSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: part.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
currentBlock.text += part.text;
|
||||
currentBlock.textSignature = retainThoughtSignature(
|
||||
currentBlock.textSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: part.text,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (part.functionCall) {
|
||||
if (currentBlock) {
|
||||
if (currentBlock.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
currentBlock = null;
|
||||
}
|
||||
|
||||
// Generate unique ID if not provided or if it's a duplicate
|
||||
const providedId = part.functionCall.id;
|
||||
const needsNewId =
|
||||
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
|
||||
const toolCallId = needsNewId
|
||||
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
||||
: providedId;
|
||||
|
||||
const toolCall: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: toolCallId,
|
||||
name: part.functionCall.name || "",
|
||||
arguments: (part.functionCall.args as Record<string, any>) ?? {},
|
||||
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
||||
};
|
||||
|
||||
output.content.push(toolCall);
|
||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: JSON.stringify(toolCall.arguments),
|
||||
partial: output,
|
||||
});
|
||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
output.rawStopReason = candidate.finishReason;
|
||||
output.stopReason = mapStopReason(candidate.finishReason);
|
||||
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
|
||||
output.stopReason = "toolUse";
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usageMetadata) {
|
||||
output.usage = {
|
||||
input:
|
||||
(chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0),
|
||||
output:
|
||||
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
|
||||
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: chunk.usageMetadata.thoughtsTokenCount || 0,
|
||||
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock) {
|
||||
if (currentBlock.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (output.stopReason === "pending") {
|
||||
throw new Error("Google stream ended without a finish reason");
|
||||
}
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
const errorMessage = output.rawStopReason
|
||||
? `Provider stopped with: ${output.rawStopReason}`
|
||||
: "An unknown error occurred";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
// Remove internal index property used during streaming
|
||||
for (const block of output.content) {
|
||||
if ("index" in block) {
|
||||
delete (block as { index?: number }).index;
|
||||
}
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatProviderError(normalizeProviderError(error));
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
export const streamSimple: StreamFunction<"google-generative-ai", SimpleStreamOptions> = (
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const base = {
|
||||
...buildBaseOptions(model, context, options, apiKey),
|
||||
toolChoice: options?.toolChoice,
|
||||
} satisfies GoogleOptions;
|
||||
if (!options?.reasoning) {
|
||||
return stream(model, context, { ...base, thinking: { enabled: false } } satisfies GoogleOptions);
|
||||
}
|
||||
|
||||
const clampedReasoning = clampThinkingLevel(model, options.reasoning);
|
||||
const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning);
|
||||
const googleModel = model as Model<"google-generative-ai">;
|
||||
|
||||
if (isGemini3ProModel(googleModel) || isGemini3FlashModel(googleModel) || isGemma4Model(googleModel)) {
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
thinking: {
|
||||
enabled: true,
|
||||
level: getThinkingLevel(resolvedLevel, googleModel),
|
||||
},
|
||||
} satisfies GoogleOptions);
|
||||
}
|
||||
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
thinking: {
|
||||
enabled: true,
|
||||
budgetTokens: getGoogleBudget(googleModel, resolvedLevel, options.thinkingBudgets),
|
||||
},
|
||||
} satisfies GoogleOptions);
|
||||
};
|
||||
|
||||
function createClient(
|
||||
model: Model<"google-generative-ai">,
|
||||
apiKey?: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
): GoogleGenAI {
|
||||
const httpOptions: { baseUrl?: string; apiVersion?: string; headers?: Record<string, string> } = {};
|
||||
if (model.baseUrl) {
|
||||
httpOptions.baseUrl = model.baseUrl;
|
||||
httpOptions.apiVersion = ""; // baseUrl already includes version path, don't append
|
||||
}
|
||||
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
|
||||
if (headers) {
|
||||
httpOptions.headers = headers;
|
||||
}
|
||||
|
||||
return new GoogleGenAI({
|
||||
apiKey,
|
||||
httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"google-generative-ai">,
|
||||
context: Context,
|
||||
options: GoogleOptions = {},
|
||||
): GenerateContentParameters {
|
||||
const contents = convertMessages(model, context);
|
||||
|
||||
const generationConfig: GenerateContentConfig = {};
|
||||
if (options.temperature !== undefined) {
|
||||
generationConfig.temperature = options.temperature;
|
||||
}
|
||||
if (options.maxTokens !== undefined) {
|
||||
generationConfig.maxOutputTokens = options.maxTokens;
|
||||
}
|
||||
|
||||
const supportsStrictMode = supportsGoogleStrictToolSampling(model.id);
|
||||
const functionCallingMode = context.tools?.length
|
||||
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode)
|
||||
: undefined;
|
||||
const config: GenerateContentConfig = {
|
||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
|
||||
...(context.tools &&
|
||||
context.tools.length > 0 && {
|
||||
tools: convertTools(context.tools, false, supportsStrictMode),
|
||||
}),
|
||||
...(functionCallingMode !== undefined && {
|
||||
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
|
||||
}),
|
||||
};
|
||||
|
||||
if (options.thinking?.enabled && model.reasoning) {
|
||||
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
|
||||
if (options.thinking.level !== undefined) {
|
||||
// Cast to any since our GoogleApiThinkingLevel mirrors Google's ThinkingLevel enum values
|
||||
thinkingConfig.thinkingLevel = options.thinking.level as any;
|
||||
} else if (options.thinking.budgetTokens !== undefined) {
|
||||
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||
}
|
||||
config.thinkingConfig = thinkingConfig;
|
||||
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
|
||||
config.thinkingConfig = getDisabledThinkingConfig(model);
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
throw new Error("Request aborted");
|
||||
}
|
||||
config.abortSignal = options.signal;
|
||||
}
|
||||
|
||||
const params: GenerateContentParameters = {
|
||||
model: model.id,
|
||||
contents,
|
||||
config,
|
||||
};
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function isGemma4Model(model: Model<"google-generative-ai">): boolean {
|
||||
return /gemma-?4/.test(model.id.toLowerCase());
|
||||
}
|
||||
|
||||
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
|
||||
return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase());
|
||||
}
|
||||
|
||||
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
||||
const id = model.id.toLowerCase();
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
|
||||
}
|
||||
|
||||
function getDisabledThinkingConfig(model: Model<"google-generative-ai">): ThinkingConfig {
|
||||
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
|
||||
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
|
||||
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
|
||||
if (isGemini3ProModel(model)) {
|
||||
return { thinkingLevel: "LOW" as any };
|
||||
}
|
||||
if (isGemini3FlashModel(model)) {
|
||||
return { thinkingLevel: "MINIMAL" as any };
|
||||
}
|
||||
if (isGemma4Model(model)) {
|
||||
return { thinkingLevel: "MINIMAL" as any };
|
||||
}
|
||||
|
||||
// Gemini 2.x supports disabling via thinkingBudget = 0.
|
||||
return { thinkingBudget: 0 };
|
||||
}
|
||||
|
||||
function getThinkingLevel(
|
||||
effort: ResolvedGoogleThinkingLevel,
|
||||
model: Model<"google-generative-ai">,
|
||||
): GoogleApiThinkingLevel {
|
||||
if (isGemini3ProModel(model)) {
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "high":
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
if (isGemma4Model(model)) {
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "MINIMAL";
|
||||
case "medium":
|
||||
case "high":
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
|
||||
function getGoogleBudget(
|
||||
model: Model<"google-generative-ai">,
|
||||
level: ResolvedGoogleThinkingLevel,
|
||||
customBudgets?: ThinkingBudgets,
|
||||
): number {
|
||||
if (customBudgets?.[level] !== undefined) {
|
||||
return customBudgets[level]!;
|
||||
}
|
||||
|
||||
if (model.id.includes("2.5-pro")) {
|
||||
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
|
||||
minimal: 128,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 32768,
|
||||
};
|
||||
return budgets[level];
|
||||
}
|
||||
|
||||
if (model.id.includes("2.5-flash-lite")) {
|
||||
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
|
||||
minimal: 512,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
};
|
||||
return budgets[level];
|
||||
}
|
||||
|
||||
if (model.id.includes("2.5-flash")) {
|
||||
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
|
||||
minimal: 128,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
};
|
||||
return budgets[level];
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
452
packages/ai/src/api/google-shared.ts
Normal file
452
packages/ai/src/api/google-shared.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Shared utilities for Google Generative AI and Google Vertex providers.
|
||||
*/
|
||||
|
||||
import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai";
|
||||
import type {
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
StopReason,
|
||||
StreamOptions,
|
||||
TextContent,
|
||||
ThinkingLevel,
|
||||
Tool,
|
||||
} from "../types.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
type GoogleApiType = "google-generative-ai" | "google-vertex";
|
||||
|
||||
/**
|
||||
* Thinking level for Gemini 3 models.
|
||||
* Mirrors Google's ThinkingLevel enum values.
|
||||
*/
|
||||
export type GoogleApiThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
|
||||
export type ResolvedGoogleThinkingLevel = Exclude<ThinkingLevel, "xhigh" | "max">;
|
||||
|
||||
/** Resolve a supported pi level or model-specific Google mapping to a standard Google level. */
|
||||
export function resolveGoogleThinkingLevel<T extends GoogleApiType>(
|
||||
model: Model<T>,
|
||||
level: ModelThinkingLevel,
|
||||
): ResolvedGoogleThinkingLevel {
|
||||
if (level === "off") return "high";
|
||||
|
||||
const mapped = model.thinkingLevelMap?.[level];
|
||||
const resolvedLevel = typeof mapped === "string" ? mapped.toLowerCase() : level;
|
||||
switch (resolvedLevel) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
case "medium":
|
||||
case "high":
|
||||
return resolvedLevel;
|
||||
default:
|
||||
throw new Error(
|
||||
`Unsupported Google thinking level mapping for ${model.provider}/${model.id}: ${level} -> ${String(mapped)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
|
||||
*
|
||||
* Protocol note (Gemini / Vertex AI thought signatures):
|
||||
* - `thought: true` is the definitive marker for thinking content (thought summaries).
|
||||
* - `thoughtSignature` is an encrypted representation of the model's internal thought process
|
||||
* used to preserve reasoning context across multi-turn interactions.
|
||||
* - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT
|
||||
* indicate the part itself is thinking content.
|
||||
* - For non-functionCall responses, the signature appears on the last part for context replay.
|
||||
* - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is;
|
||||
* do not merge/move signatures across parts.
|
||||
*
|
||||
* See: https://ai.google.dev/gemini-api/docs/thought-signatures
|
||||
*/
|
||||
export function isThinkingPart(part: Pick<Part, "thought" | "thoughtSignature">): boolean {
|
||||
return part.thought === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain thought signatures during streaming.
|
||||
*
|
||||
* Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it.
|
||||
* This helper preserves the last non-empty signature for the current block.
|
||||
*
|
||||
* Note: this does NOT merge or move signatures across distinct response parts. It only prevents
|
||||
* a signature from being overwritten with `undefined` within the same streamed block.
|
||||
*/
|
||||
export function retainThoughtSignature(existing: string | undefined, incoming: string | undefined): string | undefined {
|
||||
if (typeof incoming === "string" && incoming.length > 0) return incoming;
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Thought signatures must be base64 for Google APIs (TYPE_BYTES).
|
||||
const base64SignaturePattern = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
function isValidThoughtSignature(signature: string | undefined): boolean {
|
||||
if (!signature) return false;
|
||||
if (signature.length % 4 !== 0) return false;
|
||||
return base64SignaturePattern.test(signature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only keep signatures from the same provider/model and with valid base64.
|
||||
*/
|
||||
function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: string | undefined): string | undefined {
|
||||
return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Models via Google APIs that require explicit tool call IDs in function calls/responses.
|
||||
*/
|
||||
export function requiresToolCallId(modelId: string): boolean {
|
||||
const geminiMajorVersion = getGeminiMajorVersion(modelId);
|
||||
return (
|
||||
modelId.startsWith("claude-") ||
|
||||
modelId.startsWith("gpt-oss-") ||
|
||||
(geminiMajorVersion !== undefined && geminiMajorVersion >= 3)
|
||||
);
|
||||
}
|
||||
|
||||
function getGeminiMajorVersion(modelId: string): number | undefined {
|
||||
const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/);
|
||||
if (!match) return undefined;
|
||||
return Number.parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
function supportsMultimodalFunctionResponse(modelId: string): boolean {
|
||||
const geminiMajorVersion = getGeminiMajorVersion(modelId);
|
||||
if (geminiMajorVersion !== undefined) {
|
||||
return geminiMajorVersion >= 3;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal messages to Gemini Content[] format.
|
||||
*/
|
||||
export function convertMessages<T extends GoogleApiType>(model: Model<T>, context: Context): Content[] {
|
||||
const contents: Content[] = [];
|
||||
const normalizeToolCallId = (id: string): string => {
|
||||
if (!requiresToolCallId(model.id)) return id;
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64);
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{ text: sanitizeSurrogates(msg.content) }],
|
||||
});
|
||||
} else {
|
||||
const parts: Part[] = msg.content.map((item) => {
|
||||
if (item.type === "text") {
|
||||
return { text: sanitizeSurrogates(item.text) };
|
||||
} else {
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: item.mimeType,
|
||||
data: item.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
if (parts.length === 0) continue;
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts,
|
||||
});
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
const parts: Part[] = [];
|
||||
// Check if message is from same provider and model - only then keep thinking blocks
|
||||
const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature);
|
||||
// Skip empty text blocks — unless they carry a thought signature. Gemini can attach
|
||||
// the signature to a part whose visible text is empty and requires it echoed back;
|
||||
// dropping it breaks the reasoning chain and the model intermittently ends mid-task
|
||||
// turns with a thought-only STOP (empty completion, no tool call).
|
||||
if ((!block.text || block.text.trim() === "") && !thoughtSignature) continue;
|
||||
parts.push({
|
||||
text: sanitizeSurrogates(block.text),
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
});
|
||||
} else if (block.type === "thinking") {
|
||||
// Only keep as thinking block if same provider AND same model
|
||||
// Otherwise convert to plain text (no tags to avoid model mimicking them)
|
||||
if (isSameProviderAndModel) {
|
||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thinkingSignature);
|
||||
// Same rule as text blocks: an empty thinking block is dropped only when it
|
||||
// carries no signature (mirrors the anthropic converter's handling).
|
||||
if ((!block.thinking || block.thinking.trim() === "") && !thoughtSignature) continue;
|
||||
parts.push({
|
||||
thought: true,
|
||||
text: sanitizeSurrogates(block.thinking),
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
});
|
||||
} else {
|
||||
// Cross-provider/model: the signature is unusable, empty blocks stay dropped.
|
||||
if (!block.thinking || block.thinking.trim() === "") continue;
|
||||
parts.push({
|
||||
text: sanitizeSurrogates(block.thinking),
|
||||
});
|
||||
}
|
||||
} else if (block.type === "toolCall") {
|
||||
const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.thoughtSignature);
|
||||
const part: Part = {
|
||||
functionCall: {
|
||||
name: block.name,
|
||||
args: block.arguments ?? {},
|
||||
...(requiresToolCallId(model.id) ? { id: block.id } : {}),
|
||||
},
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
};
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) continue;
|
||||
contents.push({
|
||||
role: "model",
|
||||
parts,
|
||||
});
|
||||
} else if (msg.role === "toolResult") {
|
||||
// Extract text and image content
|
||||
const textContent = msg.content.filter((c): c is TextContent => c.type === "text");
|
||||
const textResult = textContent.map((c) => c.text).join("\n");
|
||||
const imageContent = model.input.includes("image")
|
||||
? msg.content.filter((c): c is ImageContent => c.type === "image")
|
||||
: [];
|
||||
|
||||
const hasText = textResult.length > 0;
|
||||
const hasImages = imageContent.length > 0;
|
||||
|
||||
// Gemini 3+ models support multimodal function responses with images nested inside
|
||||
// functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist /
|
||||
// Gemini < 3 still needs a separate user image turn.
|
||||
const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id);
|
||||
|
||||
// Use "output" key for success, "error" key for errors as per SDK documentation
|
||||
const responseValue = hasText ? sanitizeSurrogates(textResult) : hasImages ? "(see attached image)" : "";
|
||||
|
||||
const imageParts: Part[] = imageContent.map((imageBlock) => ({
|
||||
inlineData: {
|
||||
mimeType: imageBlock.mimeType,
|
||||
data: imageBlock.data,
|
||||
},
|
||||
}));
|
||||
|
||||
const includeId = requiresToolCallId(model.id);
|
||||
const functionResponsePart: Part = {
|
||||
functionResponse: {
|
||||
name: msg.toolName,
|
||||
response: msg.isError ? { error: responseValue } : { output: responseValue },
|
||||
...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }),
|
||||
...(includeId ? { id: msg.toolCallId } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Cloud Code Assist API requires all function responses to be in a single user turn.
|
||||
// Check if the last content is already a user turn with function responses and merge.
|
||||
const lastContent = contents[contents.length - 1];
|
||||
if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) {
|
||||
lastContent.parts.push(functionResponsePart);
|
||||
} else {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [functionResponsePart],
|
||||
});
|
||||
}
|
||||
|
||||
// For Gemini < 3, add images in a separate user message
|
||||
if (hasImages && !modelSupportsMultimodalFunctionResponse) {
|
||||
contents.push({
|
||||
role: "user",
|
||||
parts: [{ text: "Tool result image:" }, ...imageParts],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
const JSON_SCHEMA_META_DECLARATIONS = new Set([
|
||||
"$schema",
|
||||
"$id",
|
||||
"$anchor",
|
||||
"$dynamicAnchor",
|
||||
"$vocabulary",
|
||||
"$comment",
|
||||
"$defs",
|
||||
"definitions", // pre-draft-2019-09 equivalent of $defs
|
||||
]);
|
||||
|
||||
/**
|
||||
* Strip meta-declarations from a schema obj
|
||||
*/
|
||||
function sanitizeForOpenApi(schema: unknown): unknown {
|
||||
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(schema)) {
|
||||
if (JSON_SCHEMA_META_DECLARATIONS.has(key)) continue;
|
||||
result[key] = sanitizeForOpenApi(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tools to Gemini function declarations format.
|
||||
*
|
||||
* By default uses `parametersJsonSchema` which supports full JSON Schema (including
|
||||
* anyOf, oneOf, const, etc.). Set `useParameters` to true to use the legacy `parameters`
|
||||
* field instead (OpenAPI 3.03 Schema). This is needed for Cloud Code Assist with Claude
|
||||
* models, where the API translates `parameters` into Anthropic's `input_schema`.
|
||||
*/
|
||||
export function convertTools(
|
||||
tools: Tool[],
|
||||
useParameters = false,
|
||||
supportsStrictMode = true,
|
||||
): { functionDeclarations: Record<string, unknown>[] }[] | undefined {
|
||||
if (tools.length === 0) return undefined;
|
||||
return [
|
||||
{
|
||||
functionDeclarations: tools.map((tool) => {
|
||||
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
|
||||
const parameters = getJsonSchemaToolParameters(tool, strict);
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
...(useParameters
|
||||
? { parameters: sanitizeForOpenApi(parameters as unknown) }
|
||||
: { parametersJsonSchema: parameters }),
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Gemini 3+ enforces required function parameters in validated tool-calling modes. */
|
||||
export function supportsGoogleStrictToolSampling(modelId: string): boolean {
|
||||
const majorVersion = getGeminiMajorVersion(modelId);
|
||||
return majorVersion !== undefined && majorVersion >= 3;
|
||||
}
|
||||
|
||||
/** Map tool choice string to Gemini FunctionCallingConfigMode. */
|
||||
export function mapToolChoice(choice: string): FunctionCallingConfigMode {
|
||||
switch (choice) {
|
||||
case "auto":
|
||||
return FunctionCallingConfigMode.AUTO;
|
||||
case "none":
|
||||
return FunctionCallingConfigMode.NONE;
|
||||
case "any":
|
||||
return FunctionCallingConfigMode.ANY;
|
||||
default:
|
||||
return FunctionCallingConfigMode.AUTO;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGoogleFunctionCallingMode(
|
||||
tools: Tool[],
|
||||
toolChoice: string | undefined,
|
||||
supportsStrictMode: boolean,
|
||||
): FunctionCallingConfigMode | undefined {
|
||||
const useStrictMode = tools.some((tool) => resolveJsonSchemaStrictSampling(tool, supportsStrictMode) === true);
|
||||
if (toolChoice === "none" || toolChoice === "any") {
|
||||
return mapToolChoice(toolChoice);
|
||||
}
|
||||
if (useStrictMode) {
|
||||
return FunctionCallingConfigMode.VALIDATED;
|
||||
}
|
||||
return toolChoice ? mapToolChoice(toolChoice) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Gemini FinishReason to our StopReason.
|
||||
*/
|
||||
export function mapStopReason(reason: FinishReason): StopReason {
|
||||
switch (reason) {
|
||||
case FinishReason.STOP:
|
||||
return "stop";
|
||||
case FinishReason.MAX_TOKENS:
|
||||
return "length";
|
||||
case FinishReason.BLOCKLIST:
|
||||
case FinishReason.PROHIBITED_CONTENT:
|
||||
case FinishReason.SPII:
|
||||
case FinishReason.SAFETY:
|
||||
case FinishReason.IMAGE_SAFETY:
|
||||
case FinishReason.IMAGE_PROHIBITED_CONTENT:
|
||||
case FinishReason.IMAGE_RECITATION:
|
||||
case FinishReason.IMAGE_OTHER:
|
||||
case FinishReason.RECITATION:
|
||||
case FinishReason.FINISH_REASON_UNSPECIFIED:
|
||||
case FinishReason.OTHER:
|
||||
case FinishReason.LANGUAGE:
|
||||
case FinishReason.MALFORMED_FUNCTION_CALL:
|
||||
case FinishReason.UNEXPECTED_TOOL_CALL:
|
||||
case FinishReason.NO_IMAGE:
|
||||
return "error";
|
||||
default: {
|
||||
const _exhaustive: never = reason;
|
||||
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map string finish reason to our StopReason (for raw API responses).
|
||||
*/
|
||||
export function mapStopReasonString(reason: string): StopReason {
|
||||
switch (reason) {
|
||||
case "STOP":
|
||||
return "stop";
|
||||
case "MAX_TOKENS":
|
||||
return "length";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Google GenAI SDK request with the shared provider retry policy
|
||||
* (408/409/429/5xx with backoff, honoring retry-after), mirroring how the
|
||||
* Anthropic and OpenAI adapters wrap their initial request in
|
||||
* retryProviderRequest. The SDK's ApiError has a `status` property but no
|
||||
* `headers` property, and retryProviderRequest only retries errors that carry
|
||||
* both, so normalize the error by adding the missing `headers` before
|
||||
* rethrowing.
|
||||
*/
|
||||
export function retryGoogleRequest<T>(
|
||||
request: () => Promise<T>,
|
||||
options?: Pick<StreamOptions, "maxRetries" | "maxRetryDelayMs" | "signal">,
|
||||
): Promise<T> {
|
||||
return retryProviderRequest(
|
||||
async () => {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "status" in error && !("headers" in error)) {
|
||||
(error as { headers?: Headers }).headers = undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
4
packages/ai/src/api/google-vertex.lazy.ts
Normal file
4
packages/ai/src/api/google-vertex.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const googleVertexApi = (): ProviderStreams => lazyApi(() => import("./google-vertex.ts"));
|
||||
598
packages/ai/src/api/google-vertex.ts
Normal file
598
packages/ai/src/api/google-vertex.ts
Normal file
@@ -0,0 +1,598 @@
|
||||
import {
|
||||
type GenerateContentConfig,
|
||||
type GenerateContentParameters,
|
||||
GoogleGenAI,
|
||||
type HttpOptions,
|
||||
ResourceScope,
|
||||
type ThinkingConfig,
|
||||
ThinkingLevel,
|
||||
} from "@google/genai";
|
||||
import { calculateCost, clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
ProviderHeaders,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TextContent,
|
||||
ThinkingBudgets,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./google-shared.ts";
|
||||
import {
|
||||
convertMessages,
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReason,
|
||||
resolveGoogleFunctionCallingMode,
|
||||
resolveGoogleThinkingLevel,
|
||||
retainThoughtSignature,
|
||||
retryGoogleRequest,
|
||||
supportsGoogleStrictToolSampling,
|
||||
} from "./google-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
||||
export interface GoogleVertexOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any";
|
||||
thinking?: {
|
||||
enabled: boolean;
|
||||
budgetTokens?: number; // -1 for dynamic, 0 to disable
|
||||
level?: GoogleApiThinkingLevel;
|
||||
};
|
||||
project?: string;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
const API_VERSION = "v1";
|
||||
const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
|
||||
|
||||
const THINKING_LEVEL_MAP: Record<GoogleApiThinkingLevel, ThinkingLevel> = {
|
||||
THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
|
||||
MINIMAL: ThinkingLevel.MINIMAL,
|
||||
LOW: ThinkingLevel.LOW,
|
||||
MEDIUM: ThinkingLevel.MEDIUM,
|
||||
HIGH: ThinkingLevel.HIGH,
|
||||
};
|
||||
|
||||
// Counter for generating unique tool call IDs
|
||||
let toolCallCounter = 0;
|
||||
|
||||
export const stream: StreamFunction<"google-vertex", GoogleVertexOptions> = (
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
options?: GoogleVertexOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "google-vertex" as Api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
if (options?.fetch && options.fetch !== globalThis.fetch) {
|
||||
throw new Error("Custom fetch is not supported by the Google Vertex adapter");
|
||||
}
|
||||
const apiKey = resolveApiKey(options);
|
||||
// Create the client using either a Vertex API key, if provided, or ADC with project and location
|
||||
const client = apiKey
|
||||
? createClientWithApiKey(model, apiKey, options?.headers)
|
||||
: createClient(model, resolveProject(options), resolveLocation(options), options?.headers, options?.env);
|
||||
let params = buildParams(model, context, options);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as GenerateContentParameters;
|
||||
}
|
||||
const googleStream = await retryGoogleRequest(() => client.models.generateContentStream(params), options);
|
||||
|
||||
stream.push({ type: "start", partial: output });
|
||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||
const blocks = output.content;
|
||||
const blockIndex = () => blocks.length - 1;
|
||||
for await (const chunk of googleStream) {
|
||||
// Vertex uses the same @google/genai GenerateContentResponse type as Gemini.
|
||||
// responseId is documented there as an output-only identifier for each response.
|
||||
output.responseId ||= chunk.responseId;
|
||||
const candidate = chunk.candidates?.[0];
|
||||
if (candidate?.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text !== undefined) {
|
||||
const isThinking = isThinkingPart(part);
|
||||
if (
|
||||
!currentBlock ||
|
||||
(isThinking && currentBlock.type !== "thinking") ||
|
||||
(!isThinking && currentBlock.type !== "text")
|
||||
) {
|
||||
if (currentBlock) {
|
||||
if (currentBlock.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blocks.length - 1,
|
||||
content: currentBlock.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isThinking) {
|
||||
currentBlock = { type: "thinking", thinking: "", thinkingSignature: undefined };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
||||
} else {
|
||||
currentBlock = { type: "text", text: "" };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
||||
}
|
||||
}
|
||||
if (currentBlock.type === "thinking") {
|
||||
currentBlock.thinking += part.text;
|
||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||
currentBlock.thinkingSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: part.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
currentBlock.text += part.text;
|
||||
currentBlock.textSignature = retainThoughtSignature(
|
||||
currentBlock.textSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: part.text,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (part.functionCall) {
|
||||
if (currentBlock) {
|
||||
if (currentBlock.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
currentBlock = null;
|
||||
}
|
||||
|
||||
const providedId = part.functionCall.id;
|
||||
const needsNewId =
|
||||
!providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId);
|
||||
const toolCallId = needsNewId
|
||||
? `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`
|
||||
: providedId;
|
||||
|
||||
const toolCall: ToolCall = {
|
||||
type: "toolCall",
|
||||
id: toolCallId,
|
||||
name: part.functionCall.name || "",
|
||||
arguments: (part.functionCall.args as Record<string, any>) ?? {},
|
||||
...(part.thoughtSignature && { thoughtSignature: part.thoughtSignature }),
|
||||
};
|
||||
|
||||
output.content.push(toolCall);
|
||||
stream.push({ type: "toolcall_start", contentIndex: blockIndex(), partial: output });
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: JSON.stringify(toolCall.arguments),
|
||||
partial: output,
|
||||
});
|
||||
stream.push({ type: "toolcall_end", contentIndex: blockIndex(), toolCall, partial: output });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
output.rawStopReason = candidate.finishReason;
|
||||
output.stopReason = mapStopReason(candidate.finishReason);
|
||||
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
|
||||
output.stopReason = "toolUse";
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usageMetadata) {
|
||||
output.usage = {
|
||||
input:
|
||||
(chunk.usageMetadata.promptTokenCount || 0) - (chunk.usageMetadata.cachedContentTokenCount || 0),
|
||||
output:
|
||||
(chunk.usageMetadata.candidatesTokenCount || 0) + (chunk.usageMetadata.thoughtsTokenCount || 0),
|
||||
cacheRead: chunk.usageMetadata.cachedContentTokenCount || 0,
|
||||
cacheWrite: 0,
|
||||
reasoning: chunk.usageMetadata.thoughtsTokenCount || 0,
|
||||
totalTokens: chunk.usageMetadata.totalTokenCount || 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBlock) {
|
||||
if (currentBlock.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.text,
|
||||
partial: output,
|
||||
});
|
||||
} else {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: currentBlock.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (output.stopReason === "pending") {
|
||||
throw new Error("Google Vertex stream ended without a finish reason");
|
||||
}
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
const errorMessage = output.rawStopReason
|
||||
? `Provider stopped with: ${output.rawStopReason}`
|
||||
: "An unknown error occurred";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
// Remove internal index property used during streaming
|
||||
for (const block of output.content) {
|
||||
if ("index" in block) {
|
||||
delete (block as { index?: number }).index;
|
||||
}
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatProviderError(normalizeProviderError(error));
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
export const streamSimple: StreamFunction<"google-vertex", SimpleStreamOptions> = (
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const base = {
|
||||
...buildBaseOptions(model, context, options, undefined),
|
||||
toolChoice: options?.toolChoice,
|
||||
} satisfies GoogleVertexOptions;
|
||||
if (!options?.reasoning) {
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
thinking: { enabled: false },
|
||||
} satisfies GoogleVertexOptions);
|
||||
}
|
||||
|
||||
const clampedReasoning = clampThinkingLevel(model, options.reasoning);
|
||||
const resolvedLevel = resolveGoogleThinkingLevel(model, clampedReasoning);
|
||||
const geminiModel = model as unknown as Model<"google-generative-ai">;
|
||||
|
||||
if (isGemini3ProModel(geminiModel) || isGemini3FlashModel(geminiModel)) {
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
thinking: {
|
||||
enabled: true,
|
||||
level: getGemini3ThinkingLevel(resolvedLevel, geminiModel),
|
||||
},
|
||||
} satisfies GoogleVertexOptions);
|
||||
}
|
||||
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
thinking: {
|
||||
enabled: true,
|
||||
budgetTokens: getGoogleBudget(geminiModel, resolvedLevel, options.thinkingBudgets),
|
||||
},
|
||||
} satisfies GoogleVertexOptions);
|
||||
};
|
||||
|
||||
function createClient(
|
||||
model: Model<"google-vertex">,
|
||||
project: string,
|
||||
location: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
env?: ProviderEnv,
|
||||
): GoogleGenAI {
|
||||
const googleAuthOptions = buildGoogleAuthOptions(env);
|
||||
return new GoogleGenAI({
|
||||
vertexai: true,
|
||||
project,
|
||||
location,
|
||||
apiVersion: API_VERSION,
|
||||
...(googleAuthOptions ? { googleAuthOptions } : {}),
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders),
|
||||
});
|
||||
}
|
||||
|
||||
function createClientWithApiKey(
|
||||
model: Model<"google-vertex">,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
): GoogleGenAI {
|
||||
return new GoogleGenAI({
|
||||
vertexai: true,
|
||||
apiKey,
|
||||
apiVersion: API_VERSION,
|
||||
httpOptions: buildHttpOptions(model, optionsHeaders),
|
||||
});
|
||||
}
|
||||
|
||||
function buildHttpOptions(model: Model<"google-vertex">, optionsHeaders?: ProviderHeaders): HttpOptions | undefined {
|
||||
const httpOptions: HttpOptions = {};
|
||||
const baseUrl = resolveCustomBaseUrl(model.baseUrl);
|
||||
if (baseUrl) {
|
||||
httpOptions.baseUrl = baseUrl;
|
||||
httpOptions.baseUrlResourceScope = ResourceScope.COLLECTION;
|
||||
if (baseUrlIncludesApiVersion(baseUrl)) {
|
||||
httpOptions.apiVersion = "";
|
||||
}
|
||||
}
|
||||
|
||||
const headers = providerHeadersToRecord({ "User-Agent": getPiUserAgent(), ...model.headers, ...optionsHeaders });
|
||||
if (headers) {
|
||||
httpOptions.headers = headers;
|
||||
}
|
||||
|
||||
return Object.keys(httpOptions).length > 0 ? httpOptions : undefined;
|
||||
}
|
||||
|
||||
function resolveCustomBaseUrl(baseUrl: string): string | undefined {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed || trimmed.includes("{location}")) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function baseUrlIncludesApiVersion(baseUrl: string): boolean {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part));
|
||||
} catch {
|
||||
return /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function buildGoogleAuthOptions(env?: ProviderEnv): { keyFilename: string } | undefined {
|
||||
const keyFilename = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
|
||||
return keyFilename ? { keyFilename } : undefined;
|
||||
}
|
||||
|
||||
function resolveApiKey(options?: GoogleVertexOptions): string | undefined {
|
||||
const apiKey = options?.apiKey?.trim();
|
||||
if (!apiKey || apiKey === GCP_VERTEX_CREDENTIALS_MARKER || isPlaceholderApiKey(apiKey)) {
|
||||
return undefined;
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function isPlaceholderApiKey(apiKey: string): boolean {
|
||||
return /^<[^>]+>$/.test(apiKey);
|
||||
}
|
||||
|
||||
function resolveProject(options?: GoogleVertexOptions): string {
|
||||
const project =
|
||||
options?.project ||
|
||||
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", options?.env) ||
|
||||
getProviderEnvValue("GCLOUD_PROJECT", options?.env);
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
"Vertex AI requires a project ID. Set GOOGLE_CLOUD_PROJECT/GCLOUD_PROJECT or pass project in options.",
|
||||
);
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
function resolveLocation(options?: GoogleVertexOptions): string {
|
||||
const location = options?.location || getProviderEnvValue("GOOGLE_CLOUD_LOCATION", options?.env);
|
||||
if (!location) {
|
||||
throw new Error("Vertex AI requires a location. Set GOOGLE_CLOUD_LOCATION or pass location in options.");
|
||||
}
|
||||
return location;
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"google-vertex">,
|
||||
context: Context,
|
||||
options: GoogleVertexOptions = {},
|
||||
): GenerateContentParameters {
|
||||
const contents = convertMessages(model, context);
|
||||
|
||||
const generationConfig: GenerateContentConfig = {};
|
||||
if (options.temperature !== undefined) {
|
||||
generationConfig.temperature = options.temperature;
|
||||
}
|
||||
if (options.maxTokens !== undefined) {
|
||||
generationConfig.maxOutputTokens = options.maxTokens;
|
||||
}
|
||||
|
||||
const supportsStrictMode = supportsGoogleStrictToolSampling(model.id);
|
||||
const functionCallingMode = context.tools?.length
|
||||
? resolveGoogleFunctionCallingMode(context.tools, options.toolChoice, supportsStrictMode)
|
||||
: undefined;
|
||||
const config: GenerateContentConfig = {
|
||||
...(Object.keys(generationConfig).length > 0 && generationConfig),
|
||||
...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
|
||||
...(context.tools &&
|
||||
context.tools.length > 0 && {
|
||||
tools: convertTools(context.tools, false, supportsStrictMode),
|
||||
}),
|
||||
...(functionCallingMode !== undefined && {
|
||||
toolConfig: { functionCallingConfig: { mode: functionCallingMode } },
|
||||
}),
|
||||
};
|
||||
|
||||
if (options.thinking?.enabled && model.reasoning) {
|
||||
const thinkingConfig: ThinkingConfig = { includeThoughts: true };
|
||||
if (options.thinking.level !== undefined) {
|
||||
thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level];
|
||||
} else if (options.thinking.budgetTokens !== undefined) {
|
||||
thinkingConfig.thinkingBudget = options.thinking.budgetTokens;
|
||||
}
|
||||
config.thinkingConfig = thinkingConfig;
|
||||
} else if (model.reasoning && options.thinking && !options.thinking.enabled) {
|
||||
config.thinkingConfig = getDisabledThinkingConfig(model);
|
||||
}
|
||||
|
||||
if (options.signal) {
|
||||
if (options.signal.aborted) {
|
||||
throw new Error("Request aborted");
|
||||
}
|
||||
config.abortSignal = options.signal;
|
||||
}
|
||||
|
||||
const params: GenerateContentParameters = {
|
||||
model: model.id,
|
||||
contents,
|
||||
config,
|
||||
};
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function isGemini3ProModel(model: Model<"google-generative-ai">): boolean {
|
||||
return /gemini-3(?:\.\d+)?-pro/.test(model.id.toLowerCase());
|
||||
}
|
||||
|
||||
function isGemini3FlashModel(model: Model<"google-generative-ai">): boolean {
|
||||
const id = model.id.toLowerCase();
|
||||
return /gemini-3(?:\.\d+)?-flash/.test(id) || id === "gemini-flash-latest" || id === "gemini-flash-lite-latest";
|
||||
}
|
||||
|
||||
function getDisabledThinkingConfig(model: Model<"google-vertex">): ThinkingConfig {
|
||||
// Google docs: Gemini 3.1 Pro cannot disable thinking, and Gemini 3 Flash / Flash-Lite
|
||||
// do not support full thinking-off either. For Gemini 3 models, use the lowest supported
|
||||
// thinkingLevel without includeThoughts so hidden thinking remains invisible to pi.
|
||||
const geminiModel = model as unknown as Model<"google-generative-ai">;
|
||||
if (isGemini3ProModel(geminiModel)) {
|
||||
return { thinkingLevel: ThinkingLevel.LOW };
|
||||
}
|
||||
if (isGemini3FlashModel(geminiModel)) {
|
||||
return { thinkingLevel: ThinkingLevel.MINIMAL };
|
||||
}
|
||||
|
||||
// Gemini 2.x supports disabling via thinkingBudget = 0.
|
||||
return { thinkingBudget: 0 };
|
||||
}
|
||||
|
||||
function getGemini3ThinkingLevel(
|
||||
effort: ResolvedGoogleThinkingLevel,
|
||||
model: Model<"google-generative-ai">,
|
||||
): GoogleApiThinkingLevel {
|
||||
if (isGemini3ProModel(model)) {
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
case "high":
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
switch (effort) {
|
||||
case "minimal":
|
||||
return "MINIMAL";
|
||||
case "low":
|
||||
return "LOW";
|
||||
case "medium":
|
||||
return "MEDIUM";
|
||||
case "high":
|
||||
return "HIGH";
|
||||
}
|
||||
}
|
||||
|
||||
function getGoogleBudget(
|
||||
model: Model<"google-generative-ai">,
|
||||
level: ResolvedGoogleThinkingLevel,
|
||||
customBudgets?: ThinkingBudgets,
|
||||
): number {
|
||||
if (customBudgets?.[level] !== undefined) {
|
||||
return customBudgets[level]!;
|
||||
}
|
||||
|
||||
if (model.id.includes("2.5-pro")) {
|
||||
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
|
||||
minimal: 128,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 32768,
|
||||
};
|
||||
return budgets[level];
|
||||
}
|
||||
|
||||
if (model.id.includes("2.5-flash")) {
|
||||
const budgets: Record<ResolvedGoogleThinkingLevel, number> = {
|
||||
minimal: 128,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
};
|
||||
return budgets[level];
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
98
packages/ai/src/api/lazy.ts
Normal file
98
packages/ai/src/api/lazy.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Api, AssistantMessage, AssistantMessageEvent, Model, ProviderStreams } from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
|
||||
function createSetupErrorMessage(model: Model<Api>, error: unknown): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function hasResult(
|
||||
source: AsyncIterable<AssistantMessageEvent>,
|
||||
): source is AsyncIterable<AssistantMessageEvent> & { result(): Promise<AssistantMessage> } {
|
||||
return typeof (source as { result?: unknown }).result === "function";
|
||||
}
|
||||
|
||||
async function forwardStream(
|
||||
target: AssistantMessageEventStream,
|
||||
source: AsyncIterable<AssistantMessageEvent>,
|
||||
): Promise<void> {
|
||||
for await (const event of source) {
|
||||
target.push(event);
|
||||
}
|
||||
target.end(hasResult(source) ? await source.result() : undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stream synchronously while running async setup (auth resolution,
|
||||
* lazy module loading) behind it. Setup failures terminate the stream with an
|
||||
* error event.
|
||||
*/
|
||||
export function lazyStream(
|
||||
model: Model<Api>,
|
||||
setup: () => Promise<AsyncIterable<AssistantMessageEvent>>,
|
||||
): AssistantMessageEventStream {
|
||||
const outer = new AssistantMessageEventStream();
|
||||
|
||||
setup()
|
||||
.then((inner) => forwardStream(outer, inner))
|
||||
.catch((error) => {
|
||||
const message = createSetupErrorMessage(model, error);
|
||||
outer.push({ type: "error", reason: "error", error: message });
|
||||
outer.end(message);
|
||||
});
|
||||
|
||||
return outer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a dynamically imported API implementation module as `ProviderStreams`.
|
||||
* The module loads on first stream call; the host's import cache deduplicates
|
||||
* loads. Load failures terminate the returned stream with an error event.
|
||||
*/
|
||||
export interface LazyApiCapabilities {
|
||||
fetchDeferred?: boolean;
|
||||
cancelDeferred?: boolean;
|
||||
}
|
||||
|
||||
export function lazyApi(load: () => Promise<ProviderStreams>, capabilities?: LazyApiCapabilities): ProviderStreams {
|
||||
const api: ProviderStreams = {
|
||||
stream: (model, context, options) =>
|
||||
lazyStream(model, async () => (await load()).stream(model, context, options)),
|
||||
streamSimple: (model, context, options) =>
|
||||
lazyStream(model, async () => (await load()).streamSimple(model, context, options)),
|
||||
};
|
||||
|
||||
if (capabilities?.fetchDeferred) {
|
||||
api.fetchDeferred = (model, handle, options) =>
|
||||
lazyStream(model, async () => {
|
||||
const implementation = await load();
|
||||
if (!implementation.fetchDeferred) throw new Error("API does not support deferred responses");
|
||||
return implementation.fetchDeferred(model, handle, options);
|
||||
});
|
||||
}
|
||||
if (capabilities?.cancelDeferred) {
|
||||
api.cancelDeferred = async (model, handle, options) => {
|
||||
const implementation = await load();
|
||||
if (!implementation.cancelDeferred) throw new Error("API cannot cancel deferred responses");
|
||||
await implementation.cancelDeferred(model, handle, options);
|
||||
};
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
4
packages/ai/src/api/mistral-conversations.lazy.ts
Normal file
4
packages/ai/src/api/mistral-conversations.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const mistralConversationsApi = (): ProviderStreams => lazyApi(() => import("./mistral-conversations.ts"));
|
||||
936
packages/ai/src/api/mistral-conversations.ts
Normal file
936
packages/ai/src/api/mistral-conversations.ts
Normal file
@@ -0,0 +1,936 @@
|
||||
import { calculateCost, clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Context,
|
||||
Message,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StopReason,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
Tool,
|
||||
ToolCall,
|
||||
} from "../types.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import { getJsonSchemaToolParameters, resolveJsonSchemaStrictSampling } from "./constrained-sampling.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
const MISTRAL_TOOL_CALL_ID_LENGTH = 9;
|
||||
const MAX_MISTRAL_ERROR_BODY_CHARS = 4000;
|
||||
|
||||
/**
|
||||
* Provider-specific options for the Mistral API.
|
||||
*/
|
||||
type MistralReasoningEffort = "none" | "high";
|
||||
|
||||
export interface MistralOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } };
|
||||
promptMode?: "reasoning";
|
||||
reasoningEffort?: MistralReasoningEffort;
|
||||
}
|
||||
|
||||
type MistralContentChunk =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image_url"; imageUrl: string }
|
||||
| { type: "thinking"; thinking: Array<{ type: "text"; text: string }> };
|
||||
|
||||
type MistralRequestToolCall = {
|
||||
id: string;
|
||||
type: "function";
|
||||
function: { name: string; arguments: string };
|
||||
index: number;
|
||||
};
|
||||
|
||||
type MistralChatMessage = {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content?: string | MistralContentChunk[];
|
||||
toolCalls?: MistralRequestToolCall[];
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
prefix?: boolean;
|
||||
};
|
||||
|
||||
type MistralFunctionTool = {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
strict: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type MistralChatPayload = {
|
||||
[key: string]: unknown;
|
||||
model: string;
|
||||
stream: boolean;
|
||||
messages: MistralChatMessage[];
|
||||
tools?: MistralFunctionTool[];
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
toolChoice?: Exclude<MistralOptions["toolChoice"], undefined>;
|
||||
promptMode?: "reasoning";
|
||||
reasoningEffort?: MistralReasoningEffort;
|
||||
promptCacheKey?: string;
|
||||
};
|
||||
|
||||
type MistralStreamContentChunk = {
|
||||
type: string;
|
||||
text?: string;
|
||||
thinking?: Array<{ text?: string }>;
|
||||
};
|
||||
|
||||
type MistralStreamToolCall = {
|
||||
id?: string;
|
||||
index?: number;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type MistralCompletionEvent = {
|
||||
data: {
|
||||
id?: string;
|
||||
usage?: {
|
||||
[key: string]: unknown;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
choices: Array<{
|
||||
finish_reason?: string | null;
|
||||
delta: {
|
||||
content?: string | MistralStreamContentChunk[] | null;
|
||||
tool_calls?: MistralStreamToolCall[] | null;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream responses from the native Mistral Chat Completions endpoint.
|
||||
*/
|
||||
export const stream: StreamFunction<"mistral-conversations", MistralOptions> = (
|
||||
model: Model<"mistral-conversations">,
|
||||
context: Context,
|
||||
options?: MistralOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
(async () => {
|
||||
const output = createOutput(model);
|
||||
|
||||
try {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const normalizeMistralToolCallId = createMistralToolCallIdNormalizer();
|
||||
const transformedMessages = transformMessages(context.messages, model, (id) => normalizeMistralToolCallId(id));
|
||||
|
||||
let payload = buildChatPayload(model, context, transformedMessages, options);
|
||||
const nextPayload = await options?.onPayload?.(payload, model);
|
||||
if (nextPayload !== undefined) {
|
||||
payload = nextPayload as MistralChatPayload;
|
||||
}
|
||||
const mistralStream = await requestMistralStream(model, payload, apiKey, options);
|
||||
stream.push({ type: "start", partial: output });
|
||||
await consumeChatStream(model, output, stream, mistralStream);
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (output.stopReason === "pending") {
|
||||
throw new Error("Mistral stream ended without a finish reason");
|
||||
}
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage || "An unknown error occurred");
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
// partialArgs is only a streaming scratch buffer; never persist it.
|
||||
delete (block as { partialArgs?: string }).partialArgs;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatMistralError(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps provider-agnostic `SimpleStreamOptions` to Mistral options.
|
||||
*/
|
||||
export const streamSimple: StreamFunction<"mistral-conversations", SimpleStreamOptions> = (
|
||||
model: Model<"mistral-conversations">,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const base = {
|
||||
...buildBaseOptions(model, context, options, apiKey),
|
||||
toolChoice: options?.toolChoice,
|
||||
} satisfies MistralOptions;
|
||||
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
|
||||
const reasoning = clampedReasoning === "off" ? undefined : clampedReasoning;
|
||||
const shouldUseReasoning = model.reasoning && reasoning !== undefined;
|
||||
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
promptMode: shouldUseReasoning && usesPromptModeReasoning(model) ? "reasoning" : undefined,
|
||||
reasoningEffort:
|
||||
shouldUseReasoning && usesReasoningEffort(model) ? mapReasoningEffort(model, reasoning) : undefined,
|
||||
} satisfies MistralOptions);
|
||||
};
|
||||
|
||||
function createOutput(model: Model<"mistral-conversations">): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createMistralToolCallIdNormalizer(): (id: string) => string {
|
||||
const idMap = new Map<string, string>();
|
||||
const reverseMap = new Map<string, string>();
|
||||
|
||||
return (id: string): string => {
|
||||
const existing = idMap.get(id);
|
||||
if (existing) return existing;
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const candidate = deriveMistralToolCallId(id, attempt);
|
||||
const owner = reverseMap.get(candidate);
|
||||
if (!owner || owner === id) {
|
||||
idMap.set(id, candidate);
|
||||
reverseMap.set(candidate, id);
|
||||
return candidate;
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function deriveMistralToolCallId(id: string, attempt: number): string {
|
||||
const normalized = id.replace(/[^a-zA-Z0-9]/g, "");
|
||||
if (attempt === 0 && normalized.length === MISTRAL_TOOL_CALL_ID_LENGTH) return normalized;
|
||||
const seedBase = normalized || id;
|
||||
const seed = attempt === 0 ? seedBase : `${seedBase}:${attempt}`;
|
||||
return shortHash(seed)
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.slice(0, MISTRAL_TOOL_CALL_ID_LENGTH);
|
||||
}
|
||||
|
||||
function formatMistralError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const httpError = error as Error & { statusCode?: unknown; body?: unknown };
|
||||
const statusCode = typeof httpError.statusCode === "number" ? httpError.statusCode : undefined;
|
||||
const bodyText = typeof httpError.body === "string" ? httpError.body.trim() : undefined;
|
||||
if (statusCode !== undefined && bodyText) {
|
||||
return `Mistral API error (${statusCode}): ${truncateErrorText(bodyText, MAX_MISTRAL_ERROR_BODY_CHARS)}`;
|
||||
}
|
||||
if (statusCode !== undefined) return `Mistral API error (${statusCode}): ${error.message}`;
|
||||
return error.message;
|
||||
}
|
||||
return safeJsonStringify(error);
|
||||
}
|
||||
|
||||
function truncateErrorText(text: string, maxChars: number): string {
|
||||
if (text.length <= maxChars) return text;
|
||||
return `${text.slice(0, maxChars)}... [truncated ${text.length - maxChars} chars]`;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized === undefined ? String(value) : serialized;
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestMistralStream(
|
||||
model: Model<"mistral-conversations">,
|
||||
payload: MistralChatPayload,
|
||||
apiKey: string,
|
||||
options?: MistralOptions,
|
||||
): Promise<AsyncIterable<MistralCompletionEvent>> {
|
||||
const baseUrl = new URL(model.baseUrl);
|
||||
baseUrl.pathname = `${baseUrl.pathname.replace(/\/+$/u, "")}/`;
|
||||
const url = new URL("v1/chat/completions", baseUrl);
|
||||
const headers = buildMistralHeaders(model, apiKey, options);
|
||||
const timeoutSignal = AbortSignal.timeout(options?.timeoutMs ?? 60_000);
|
||||
const signal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
||||
const response = await (options?.fetch ?? globalThis.fetch)(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(toMistralWirePayload(payload)),
|
||||
signal,
|
||||
});
|
||||
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new MistralHttpError(response.status, body, response.statusText);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Mistral response has no body");
|
||||
}
|
||||
|
||||
return readMistralEvents(response.body, signal);
|
||||
}
|
||||
|
||||
class MistralHttpError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, body: string, statusText: string) {
|
||||
super(statusText || `Request failed with status ${statusCode}`);
|
||||
this.name = "MistralHttpError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
function buildMistralHeaders(model: Model<"mistral-conversations">, apiKey: string, options?: MistralOptions): Headers {
|
||||
const headers = new Headers({
|
||||
"User-Agent": getPiUserAgent(),
|
||||
accept: "text/event-stream",
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
"content-type": "application/json",
|
||||
});
|
||||
applyMistralHeaderOverrides(headers, model.headers);
|
||||
applyMistralHeaderOverrides(headers, options?.headers);
|
||||
|
||||
const hasExplicitAffinity =
|
||||
hasMistralHeaderOverride(model.headers, "x-affinity") || hasMistralHeaderOverride(options?.headers, "x-affinity");
|
||||
if (shouldUsePromptCaching(options) && !hasExplicitAffinity) {
|
||||
headers.set("x-affinity", options.sessionId);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function applyMistralHeaderOverrides(headers: Headers, overrides?: Record<string, string | null>): void {
|
||||
if (!overrides) return;
|
||||
for (const [name, value] of Object.entries(overrides)) {
|
||||
if (value === null) headers.delete(name);
|
||||
else headers.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
function hasMistralHeaderOverride(overrides: Record<string, string | null> | undefined, target: string): boolean {
|
||||
return !!overrides && Object.keys(overrides).some((name) => name.toLowerCase() === target);
|
||||
}
|
||||
|
||||
function toMistralWirePayload(payload: MistralChatPayload): Record<string, unknown> {
|
||||
const wirePayload: Record<string, unknown> = { ...payload };
|
||||
for (const [source, target] of [
|
||||
["topP", "top_p"],
|
||||
["maxTokens", "max_tokens"],
|
||||
["randomSeed", "random_seed"],
|
||||
["responseFormat", "response_format"],
|
||||
["toolChoice", "tool_choice"],
|
||||
["presencePenalty", "presence_penalty"],
|
||||
["frequencyPenalty", "frequency_penalty"],
|
||||
["parallelToolCalls", "parallel_tool_calls"],
|
||||
["reasoningEffort", "reasoning_effort"],
|
||||
["promptMode", "prompt_mode"],
|
||||
["promptCacheKey", "prompt_cache_key"],
|
||||
["safePrompt", "safe_prompt"],
|
||||
] as const) {
|
||||
remapMistralProperty(wirePayload, source, target);
|
||||
}
|
||||
wirePayload.messages = payload.messages.map((message) => toMistralWireMessage(message));
|
||||
|
||||
const responseFormat = wirePayload.response_format;
|
||||
if (isMistralRecord(responseFormat)) {
|
||||
const wireResponseFormat = { ...responseFormat };
|
||||
remapMistralProperty(wireResponseFormat, "jsonSchema", "json_schema");
|
||||
const jsonSchema = wireResponseFormat.json_schema;
|
||||
if (isMistralRecord(jsonSchema)) {
|
||||
const wireJsonSchema = { ...jsonSchema };
|
||||
remapMistralProperty(wireJsonSchema, "schemaDefinition", "schema");
|
||||
wireResponseFormat.json_schema = wireJsonSchema;
|
||||
}
|
||||
wirePayload.response_format = wireResponseFormat;
|
||||
}
|
||||
|
||||
return wirePayload;
|
||||
}
|
||||
|
||||
function toMistralWireMessage(message: MistralChatMessage): Record<string, unknown> {
|
||||
const wireMessage: Record<string, unknown> = { ...message };
|
||||
remapMistralProperty(wireMessage, "toolCalls", "tool_calls");
|
||||
remapMistralProperty(wireMessage, "toolCallId", "tool_call_id");
|
||||
if (Array.isArray(message.content)) {
|
||||
wireMessage.content = message.content.map((chunk) => toMistralWireContentChunk(chunk));
|
||||
}
|
||||
return wireMessage;
|
||||
}
|
||||
|
||||
function toMistralWireContentChunk(chunk: MistralContentChunk): Record<string, unknown> {
|
||||
const wireChunk: Record<string, unknown> = { ...chunk };
|
||||
for (const [source, target] of [
|
||||
["imageUrl", "image_url"],
|
||||
["documentUrl", "document_url"],
|
||||
["documentName", "document_name"],
|
||||
["fileId", "file_id"],
|
||||
["referenceIds", "reference_ids"],
|
||||
["inputAudio", "input_audio"],
|
||||
] as const) {
|
||||
remapMistralProperty(wireChunk, source, target);
|
||||
}
|
||||
return wireChunk;
|
||||
}
|
||||
|
||||
function remapMistralProperty(record: Record<string, unknown>, source: string, target: string): void {
|
||||
if (!(source in record)) return;
|
||||
record[target] = record[source];
|
||||
delete record[source];
|
||||
}
|
||||
|
||||
function isMistralRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const MISTRAL_STREAM_DONE = Symbol("mistral-stream-done");
|
||||
|
||||
async function* readMistralEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<MistralCompletionEvent> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const onAbort = () => {
|
||||
void reader.cancel().catch(() => {});
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal.aborted) throw signal.reason;
|
||||
const { done, value } = await reader.read();
|
||||
if (signal.aborted) throw signal.reason;
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
||||
|
||||
let boundary = findMistralEventBoundary(buffer);
|
||||
while (boundary) {
|
||||
const event = parseMistralEvent(buffer.slice(0, boundary.index));
|
||||
buffer = buffer.slice(boundary.index + boundary.length);
|
||||
if (event === MISTRAL_STREAM_DONE) return;
|
||||
if (event) yield event;
|
||||
boundary = findMistralEventBoundary(buffer);
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const event = parseMistralEvent(buffer);
|
||||
if (event !== MISTRAL_STREAM_DONE && event) yield event;
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function findMistralEventBoundary(buffer: string): { index: number; length: number } | undefined {
|
||||
const match = /\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\r\r|\n\r|\n\n/u.exec(buffer);
|
||||
return match?.index === undefined ? undefined : { index: match.index, length: match[0].length };
|
||||
}
|
||||
|
||||
function parseMistralEvent(raw: string): MistralCompletionEvent | typeof MISTRAL_STREAM_DONE | undefined {
|
||||
const data = raw
|
||||
.split(/\r\n|\r|\n/u)
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (!data) return undefined;
|
||||
if (data === "[DONE]") return MISTRAL_STREAM_DONE;
|
||||
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
if (!isMistralRecord(parsed) || !Array.isArray(parsed.choices)) {
|
||||
throw new Error("Invalid Mistral streaming event");
|
||||
}
|
||||
return { data: parsed as MistralCompletionEvent["data"] };
|
||||
}
|
||||
|
||||
function buildChatPayload(
|
||||
model: Model<"mistral-conversations">,
|
||||
context: Context,
|
||||
messages: Message[],
|
||||
options?: MistralOptions,
|
||||
): MistralChatPayload {
|
||||
const payload: MistralChatPayload = {
|
||||
model: model.id,
|
||||
stream: true,
|
||||
messages: toChatMessages(messages, model.input.includes("image")),
|
||||
};
|
||||
|
||||
if (context.tools?.length) payload.tools = toFunctionTools(context.tools);
|
||||
if (options?.temperature !== undefined) payload.temperature = options.temperature;
|
||||
if (options?.maxTokens !== undefined) payload.maxTokens = options.maxTokens;
|
||||
if (options?.toolChoice) payload.toolChoice = mapToolChoice(options.toolChoice);
|
||||
if (options?.promptMode) payload.promptMode = options.promptMode;
|
||||
if (options?.reasoningEffort) payload.reasoningEffort = options.reasoningEffort;
|
||||
if (shouldUsePromptCaching(options)) payload.promptCacheKey = options.sessionId;
|
||||
|
||||
if (context.systemPrompt) {
|
||||
payload.messages.unshift({
|
||||
role: "system",
|
||||
content: sanitizeSurrogates(context.systemPrompt),
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function shouldUsePromptCaching(options?: MistralOptions): options is MistralOptions & { sessionId: string } {
|
||||
return options?.cacheRetention !== "none" && !!options?.sessionId;
|
||||
}
|
||||
|
||||
function getMistralCachedPromptTokens(usage: unknown, promptTokens: number): number {
|
||||
const rawUsage = usage as {
|
||||
promptTokensDetails?: { cachedTokens?: unknown } | null;
|
||||
prompt_tokens_details?: { cached_tokens?: unknown } | null;
|
||||
promptTokenDetails?: { cachedTokens?: unknown } | null;
|
||||
prompt_token_details?: { cached_tokens?: unknown } | null;
|
||||
numCachedTokens?: unknown;
|
||||
num_cached_tokens?: unknown;
|
||||
};
|
||||
const rawCachedTokens =
|
||||
rawUsage.promptTokensDetails?.cachedTokens ??
|
||||
rawUsage.prompt_tokens_details?.cached_tokens ??
|
||||
rawUsage.promptTokenDetails?.cachedTokens ??
|
||||
rawUsage.prompt_token_details?.cached_tokens ??
|
||||
rawUsage.numCachedTokens ??
|
||||
rawUsage.num_cached_tokens ??
|
||||
0;
|
||||
const cachedTokens = typeof rawCachedTokens === "number" && Number.isFinite(rawCachedTokens) ? rawCachedTokens : 0;
|
||||
return Math.min(promptTokens, Math.max(0, cachedTokens));
|
||||
}
|
||||
|
||||
async function consumeChatStream(
|
||||
model: Model<"mistral-conversations">,
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
mistralStream: AsyncIterable<MistralCompletionEvent>,
|
||||
): Promise<void> {
|
||||
let currentBlock: TextContent | ThinkingContent | null = null;
|
||||
const blocks = output.content;
|
||||
const blockIndex = () => blocks.length - 1;
|
||||
const toolBlocksByKey = new Map<string | number, number>();
|
||||
|
||||
const finishCurrentBlock = (block?: typeof currentBlock) => {
|
||||
if (!block) return;
|
||||
if (block.type === "text") {
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: block.text,
|
||||
partial: output,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: blockIndex(),
|
||||
content: block.thinking,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for await (const event of mistralStream) {
|
||||
const chunk = event.data;
|
||||
// Mistral's streamed CompletionChunk carries an id field. Keep the first non-empty one,
|
||||
// mirroring how OpenAI-style streaming exposes a stable response identifier per stream.
|
||||
output.responseId ||= chunk.id;
|
||||
|
||||
if (chunk.usage) {
|
||||
const promptTokens = chunk.usage.prompt_tokens || 0;
|
||||
const cachedPromptTokens = getMistralCachedPromptTokens(chunk.usage, promptTokens);
|
||||
|
||||
output.usage.input = Math.max(0, promptTokens - cachedPromptTokens);
|
||||
output.usage.output = chunk.usage.completion_tokens || 0;
|
||||
output.usage.cacheRead = cachedPromptTokens;
|
||||
output.usage.cacheWrite = 0;
|
||||
output.usage.totalTokens =
|
||||
chunk.usage.total_tokens ||
|
||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
||||
calculateCost(model, output.usage);
|
||||
}
|
||||
|
||||
const choice = chunk.choices[0];
|
||||
if (!choice) continue;
|
||||
|
||||
if (choice.finish_reason) {
|
||||
output.rawStopReason = choice.finish_reason;
|
||||
const stopReasonResult = mapChatStopReason(choice.finish_reason);
|
||||
output.stopReason = stopReasonResult.stopReason;
|
||||
if (stopReasonResult.errorMessage) {
|
||||
output.errorMessage = stopReasonResult.errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
const delta = choice.delta;
|
||||
if (delta.content !== null && delta.content !== undefined) {
|
||||
const contentItems = typeof delta.content === "string" ? [delta.content] : delta.content;
|
||||
for (const item of contentItems) {
|
||||
if (typeof item === "string") {
|
||||
const textDelta = sanitizeSurrogates(item);
|
||||
if (!currentBlock || currentBlock.type !== "text") {
|
||||
finishCurrentBlock(currentBlock);
|
||||
currentBlock = { type: "text", text: "" };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
||||
}
|
||||
currentBlock.text += textDelta;
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: textDelta,
|
||||
partial: output,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === "thinking") {
|
||||
const deltaText = (item.thinking ?? [])
|
||||
.map((part) => part.text ?? "")
|
||||
.filter((text) => text.length > 0)
|
||||
.join("");
|
||||
const thinkingDelta = sanitizeSurrogates(deltaText);
|
||||
if (!thinkingDelta) continue;
|
||||
if (!currentBlock || currentBlock.type !== "thinking") {
|
||||
finishCurrentBlock(currentBlock);
|
||||
currentBlock = { type: "thinking", thinking: "" };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
|
||||
}
|
||||
currentBlock.thinking += thinkingDelta;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: thinkingDelta,
|
||||
partial: output,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.type === "text") {
|
||||
const textDelta = sanitizeSurrogates(item.text ?? "");
|
||||
if (!currentBlock || currentBlock.type !== "text") {
|
||||
finishCurrentBlock(currentBlock);
|
||||
currentBlock = { type: "text", text: "" };
|
||||
output.content.push(currentBlock);
|
||||
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
|
||||
}
|
||||
currentBlock.text += textDelta;
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: blockIndex(),
|
||||
delta: textDelta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolCalls = delta.tool_calls || [];
|
||||
for (const toolCall of toolCalls) {
|
||||
if (currentBlock) {
|
||||
finishCurrentBlock(currentBlock);
|
||||
currentBlock = null;
|
||||
}
|
||||
const callId =
|
||||
toolCall.id && toolCall.id !== "null"
|
||||
? toolCall.id
|
||||
: deriveMistralToolCallId(`toolcall:${toolCall.index ?? 0}`, 0);
|
||||
const key = toolCall.index ?? callId;
|
||||
const existingIndex = toolBlocksByKey.get(key);
|
||||
let block: (ToolCall & { partialArgs?: string }) | undefined;
|
||||
|
||||
if (existingIndex !== undefined) {
|
||||
const existing = output.content[existingIndex];
|
||||
if (existing?.type === "toolCall") {
|
||||
block = existing as ToolCall & { partialArgs?: string };
|
||||
}
|
||||
}
|
||||
|
||||
if (!block) {
|
||||
block = {
|
||||
type: "toolCall",
|
||||
id: callId,
|
||||
name: toolCall.function.name,
|
||||
arguments: {},
|
||||
partialArgs: "",
|
||||
};
|
||||
output.content.push(block);
|
||||
toolBlocksByKey.set(key, output.content.length - 1);
|
||||
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
||||
}
|
||||
|
||||
const argsDelta =
|
||||
typeof toolCall.function.arguments === "string"
|
||||
? toolCall.function.arguments
|
||||
: JSON.stringify(toolCall.function.arguments || {});
|
||||
block.partialArgs = (block.partialArgs || "") + argsDelta;
|
||||
block.arguments = parseStreamingJson<Record<string, unknown>>(block.partialArgs);
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: toolBlocksByKey.get(key)!,
|
||||
delta: argsDelta,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
finishCurrentBlock(currentBlock);
|
||||
for (const index of toolBlocksByKey.values()) {
|
||||
const block = output.content[index];
|
||||
if (block.type !== "toolCall") continue;
|
||||
const toolBlock = block as ToolCall & { partialArgs?: string };
|
||||
toolBlock.arguments = parseStreamingJson<Record<string, unknown>>(toolBlock.partialArgs);
|
||||
// Finalize in-place and strip the scratch buffer so replay only
|
||||
// carries parsed arguments.
|
||||
delete toolBlock.partialArgs;
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: index,
|
||||
toolCall: toolBlock,
|
||||
partial: output,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function toFunctionTools(tools: Tool[]): MistralFunctionTool[] {
|
||||
return tools.map((tool) => {
|
||||
const strict = resolveJsonSchemaStrictSampling(tool, true);
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: stripSymbolKeys(getJsonSchemaToolParameters(tool, strict)) as Record<string, unknown>,
|
||||
strict: strict ?? false,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function stripSymbolKeys(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => stripSymbolKeys(item));
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = stripSymbolKeys(entry);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function toChatMessages(messages: Message[], supportsImages: boolean): MistralChatMessage[] {
|
||||
const result: MistralChatMessage[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
result.push({ role: "user", content: sanitizeSurrogates(msg.content) });
|
||||
continue;
|
||||
}
|
||||
const hadImages = msg.content.some((item) => item.type === "image");
|
||||
const content: MistralContentChunk[] = msg.content
|
||||
.filter((item) => item.type === "text" || supportsImages)
|
||||
.map((item) => {
|
||||
if (item.type === "text") return { type: "text", text: sanitizeSurrogates(item.text) };
|
||||
return { type: "image_url", imageUrl: `data:${item.mimeType};base64,${item.data}` };
|
||||
});
|
||||
if (content.length > 0) {
|
||||
result.push({ role: "user", content });
|
||||
continue;
|
||||
}
|
||||
if (hadImages && !supportsImages) {
|
||||
result.push({ role: "user", content: "(image omitted: model does not support images)" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const contentParts: MistralContentChunk[] = [];
|
||||
const toolCalls: MistralRequestToolCall[] = [];
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "text") {
|
||||
if (block.text.trim().length > 0) {
|
||||
contentParts.push({ type: "text", text: sanitizeSurrogates(block.text) });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (block.type === "thinking") {
|
||||
if (block.thinking.trim().length > 0) {
|
||||
contentParts.push({
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: sanitizeSurrogates(block.thinking) }],
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: "function",
|
||||
function: { name: block.name, arguments: JSON.stringify(block.arguments || {}) },
|
||||
index: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const assistantMessage: MistralChatMessage = { role: "assistant", prefix: false };
|
||||
if (contentParts.length > 0) assistantMessage.content = contentParts;
|
||||
if (toolCalls.length > 0) assistantMessage.toolCalls = toolCalls;
|
||||
if (contentParts.length > 0 || toolCalls.length > 0) result.push(assistantMessage);
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolContent: MistralContentChunk[] = [];
|
||||
const textResult = msg.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => (part.type === "text" ? sanitizeSurrogates(part.text) : ""))
|
||||
.join("\n");
|
||||
const hasImages = msg.content.some((part) => part.type === "image");
|
||||
const toolText = buildToolResultText(textResult, hasImages, supportsImages, msg.isError);
|
||||
toolContent.push({ type: "text", text: toolText });
|
||||
for (const part of msg.content) {
|
||||
if (!supportsImages) continue;
|
||||
if (part.type !== "image") continue;
|
||||
toolContent.push({
|
||||
type: "image_url",
|
||||
imageUrl: `data:${part.mimeType};base64,${part.data}`,
|
||||
});
|
||||
}
|
||||
result.push({
|
||||
role: "tool",
|
||||
toolCallId: msg.toolCallId,
|
||||
name: msg.toolName,
|
||||
content: toolContent,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildToolResultText(text: string, hasImages: boolean, supportsImages: boolean, isError: boolean): string {
|
||||
const trimmed = text.trim();
|
||||
const errorPrefix = isError ? "[tool error] " : "";
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
const imageSuffix = hasImages && !supportsImages ? "\n[tool image omitted: model does not support images]" : "";
|
||||
return `${errorPrefix}${trimmed}${imageSuffix}`;
|
||||
}
|
||||
|
||||
if (hasImages) {
|
||||
if (supportsImages) {
|
||||
return isError ? "[tool error] (see attached image)" : "(see attached image)";
|
||||
}
|
||||
return isError
|
||||
? "[tool error] (image omitted: model does not support images)"
|
||||
: "(image omitted: model does not support images)";
|
||||
}
|
||||
|
||||
return isError ? "[tool error] (no tool output)" : "(no tool output)";
|
||||
}
|
||||
|
||||
function usesReasoningEffort(model: Model<"mistral-conversations">): boolean {
|
||||
return model.id === "mistral-small-2603" || model.id === "mistral-small-latest" || model.id === "mistral-medium-3.5";
|
||||
}
|
||||
|
||||
function usesPromptModeReasoning(model: Model<"mistral-conversations">): boolean {
|
||||
return model.reasoning && !usesReasoningEffort(model);
|
||||
}
|
||||
|
||||
function mapReasoningEffort(
|
||||
model: Model<"mistral-conversations">,
|
||||
level: Exclude<SimpleStreamOptions["reasoning"], undefined>,
|
||||
): MistralReasoningEffort {
|
||||
return (model.thinkingLevelMap?.[level] ?? "high") as MistralReasoningEffort;
|
||||
}
|
||||
|
||||
function mapToolChoice(
|
||||
choice: MistralOptions["toolChoice"],
|
||||
): "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } } | undefined {
|
||||
if (!choice) return undefined;
|
||||
if (choice === "auto" || choice === "none" || choice === "any" || choice === "required") {
|
||||
return choice;
|
||||
}
|
||||
return {
|
||||
type: "function",
|
||||
function: { name: choice.function.name },
|
||||
};
|
||||
}
|
||||
|
||||
function mapChatStopReason(reason: string | null): { stopReason: StopReason; errorMessage?: string } {
|
||||
if (reason === null) return { stopReason: "stop" };
|
||||
switch (reason) {
|
||||
case "stop":
|
||||
return { stopReason: "stop" };
|
||||
case "length":
|
||||
case "model_length":
|
||||
return { stopReason: "length" };
|
||||
case "tool_calls":
|
||||
return { stopReason: "toolUse" };
|
||||
case "error":
|
||||
return { stopReason: "error", errorMessage: "Provider stopped with: error" };
|
||||
default:
|
||||
return { stopReason: "error", errorMessage: `Provider stopped with: ${reason}` };
|
||||
}
|
||||
}
|
||||
4
packages/ai/src/api/openai-codex-responses.lazy.ts
Normal file
4
packages/ai/src/api/openai-codex-responses.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const openAICodexResponsesApi = (): ProviderStreams => lazyApi(() => import("./openai-codex-responses.ts"));
|
||||
1653
packages/ai/src/api/openai-codex-responses.ts
Normal file
1653
packages/ai/src/api/openai-codex-responses.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
packages/ai/src/api/openai-completions.lazy.ts
Normal file
4
packages/ai/src/api/openai-completions.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const openAICompletionsApi = (): ProviderStreams => lazyApi(() => import("./openai-completions.ts"));
|
||||
1717
packages/ai/src/api/openai-completions.ts
Normal file
1717
packages/ai/src/api/openai-completions.ts
Normal file
File diff suppressed because it is too large
Load Diff
8
packages/ai/src/api/openai-prompt-cache.ts
Normal file
8
packages/ai/src/api/openai-prompt-cache.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64;
|
||||
|
||||
export function clampOpenAIPromptCacheKey(key: string | undefined): string | undefined {
|
||||
if (key === undefined) return undefined;
|
||||
const chars = Array.from(key);
|
||||
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key;
|
||||
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("");
|
||||
}
|
||||
793
packages/ai/src/api/openai-responses-shared.ts
Normal file
793
packages/ai/src/api/openai-responses-shared.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
import type OpenAI from "openai";
|
||||
import type {
|
||||
Tool as OpenAITool,
|
||||
ResponseCreateParamsStreaming,
|
||||
ResponseInput,
|
||||
ResponseInputContent,
|
||||
ResponseInputImage,
|
||||
ResponseInputItem,
|
||||
ResponseInputText,
|
||||
ResponseOutputItem,
|
||||
ResponseOutputMessage,
|
||||
ResponseReasoningItem,
|
||||
ResponseStreamEvent,
|
||||
ResponseToolSearchOutputItemParam,
|
||||
} from "openai/resources/responses/responses.js";
|
||||
import { calculateCost } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
Context,
|
||||
ImageContent,
|
||||
Model,
|
||||
StopReason,
|
||||
TextContent,
|
||||
TextSignatureV1,
|
||||
ThinkingContent,
|
||||
Tool,
|
||||
ToolCall,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import type { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { shortHash } from "../utils/hash.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
import {
|
||||
appendGrammarToolInputJsonDelta,
|
||||
type GrammarToolInputJsonBuffer,
|
||||
getGrammarToolInput,
|
||||
getJsonSchemaToolParameters,
|
||||
resolveGrammarConstrainedSampling,
|
||||
resolveJsonSchemaStrictSampling,
|
||||
} from "./constrained-sampling.ts";
|
||||
import { transformMessages } from "./transform-messages.ts";
|
||||
|
||||
// =============================================================================
|
||||
// Utilities
|
||||
// =============================================================================
|
||||
|
||||
function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string {
|
||||
const payload: TextSignatureV1 = { v: 1, id };
|
||||
if (phase) payload.phase = phase;
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function parseTextSignature(
|
||||
signature: string | undefined,
|
||||
): { id: string; phase?: TextSignatureV1["phase"] } | undefined {
|
||||
if (!signature) return undefined;
|
||||
if (signature.startsWith("{")) {
|
||||
try {
|
||||
const parsed = JSON.parse(signature) as Partial<TextSignatureV1>;
|
||||
if (parsed.v === 1 && typeof parsed.id === "string") {
|
||||
if (parsed.phase === "commentary" || parsed.phase === "final_answer") {
|
||||
return { id: parsed.id, phase: parsed.phase };
|
||||
}
|
||||
return { id: parsed.id };
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy plain-string handling.
|
||||
}
|
||||
}
|
||||
return { id: signature };
|
||||
}
|
||||
|
||||
type ToolResultOutputContent = Array<ResponseInputText | ResponseInputImage>;
|
||||
|
||||
function convertToolResultOutput<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
content: readonly (TextContent | ImageContent)[],
|
||||
): string | ToolResultOutputContent {
|
||||
const textResult = content
|
||||
.filter((c): c is TextContent => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
const images = content.filter((c): c is ImageContent => c.type === "image");
|
||||
const hasText = textResult.length > 0;
|
||||
|
||||
if (images.length === 0 || !model.input.includes("image")) {
|
||||
return sanitizeSurrogates(hasText ? textResult : images.length > 0 ? "(see attached image)" : "(no tool output)");
|
||||
}
|
||||
|
||||
const output: ToolResultOutputContent = [];
|
||||
if (hasText) {
|
||||
output.push({ type: "input_text", text: sanitizeSurrogates(textResult) });
|
||||
}
|
||||
for (const image of images) {
|
||||
output.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${image.mimeType};base64,${image.data}`,
|
||||
});
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export interface OpenAIResponsesStreamOptions {
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
resolveServiceTier?: (
|
||||
responseServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
requestServiceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
) => ResponseCreateParamsStreaming["service_tier"] | undefined;
|
||||
applyServiceTierPricing?: (
|
||||
usage: Usage,
|
||||
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface ConvertResponsesMessagesOptions {
|
||||
includeSystemPrompt?: boolean;
|
||||
grammarToolInputProperties?: ReadonlyMap<string, string>;
|
||||
deferredTools?: ReadonlyMap<string, Tool>;
|
||||
deferredToolsMode?: "additional-tools" | "tool-search";
|
||||
toolOptions?: ConvertResponsesToolsOptions;
|
||||
}
|
||||
|
||||
export interface ConvertResponsesToolsOptions {
|
||||
strict?: boolean | null;
|
||||
supportsStrictMode?: boolean;
|
||||
supportsOpenAIGrammarTools?: boolean;
|
||||
deferLoading?: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message conversion
|
||||
// =============================================================================
|
||||
|
||||
export function convertResponsesMessages<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
allowedToolCallProviders: ReadonlySet<string>,
|
||||
options?: ConvertResponsesMessagesOptions,
|
||||
): ResponseInput {
|
||||
const messages: ResponseInput = [];
|
||||
const loadedToolNames = new Set<string>();
|
||||
|
||||
const normalizeIdPart = (part: string): string => {
|
||||
const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const normalized = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
|
||||
return normalized.replace(/_+$/, "");
|
||||
};
|
||||
|
||||
const buildForeignResponsesItemId = (itemId: string): string => {
|
||||
const normalized = `fc_${shortHash(itemId)}`;
|
||||
return normalized.length > 64 ? normalized.slice(0, 64) : normalized;
|
||||
};
|
||||
|
||||
const normalizeToolCallId = (id: string, _targetModel: Model<TApi>, source: AssistantMessage): string => {
|
||||
if (!allowedToolCallProviders.has(model.provider)) return normalizeIdPart(id);
|
||||
if (!id.includes("|")) return normalizeIdPart(id);
|
||||
const [callId, itemId] = id.split("|");
|
||||
const normalizedCallId = normalizeIdPart(callId);
|
||||
const isForeignToolCall = source.provider !== model.provider || source.api !== model.api;
|
||||
let normalizedItemId = isForeignToolCall ? buildForeignResponsesItemId(itemId) : normalizeIdPart(itemId);
|
||||
// OpenAI Responses API requires item id to start with "fc"
|
||||
if (!normalizedItemId.startsWith("fc_")) {
|
||||
normalizedItemId = normalizeIdPart(`fc_${normalizedItemId}`);
|
||||
}
|
||||
return `${normalizedCallId}|${normalizedItemId}`;
|
||||
};
|
||||
|
||||
const transformedMessages = transformMessages(context.messages, model, normalizeToolCallId);
|
||||
|
||||
const includeSystemPrompt = options?.includeSystemPrompt ?? true;
|
||||
if (includeSystemPrompt && context.systemPrompt) {
|
||||
const compat = model.compat as { supportsDeveloperRole?: boolean } | undefined;
|
||||
const role = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
||||
messages.push({
|
||||
role,
|
||||
content: sanitizeSurrogates(context.systemPrompt),
|
||||
});
|
||||
}
|
||||
|
||||
let msgIndex = 0;
|
||||
for (const msg of transformedMessages) {
|
||||
if (msg.role === "user") {
|
||||
if (typeof msg.content === "string") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: sanitizeSurrogates(msg.content) }],
|
||||
});
|
||||
} else {
|
||||
const content: ResponseInputContent[] = msg.content.map((item): ResponseInputContent => {
|
||||
if (item.type === "text") {
|
||||
return {
|
||||
type: "input_text",
|
||||
text: sanitizeSurrogates(item.text),
|
||||
} satisfies ResponseInputText;
|
||||
}
|
||||
return {
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${item.mimeType};base64,${item.data}`,
|
||||
} satisfies ResponseInputImage;
|
||||
});
|
||||
if (content.length === 0) continue;
|
||||
messages.push({
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
}
|
||||
} else if (msg.role === "assistant") {
|
||||
const output: ResponseInput = [];
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
const isSameProviderAndApi = assistantMsg.provider === model.provider && assistantMsg.api === model.api;
|
||||
const isSameModel = isSameProviderAndApi && assistantMsg.model === model.id;
|
||||
const isDifferentModel = isSameProviderAndApi && assistantMsg.model !== model.id;
|
||||
let textBlockIndex = 0;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (block.type === "thinking") {
|
||||
if (block.thinkingSignature) {
|
||||
const reasoningItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem;
|
||||
output.push(reasoningItem);
|
||||
}
|
||||
} else if (block.type === "text") {
|
||||
const textBlock = block as TextContent;
|
||||
const parsedSignature = parseTextSignature(textBlock.textSignature);
|
||||
const fallbackMessageId =
|
||||
textBlockIndex === 0 ? `msg_pi_${msgIndex}` : `msg_pi_${msgIndex}_${textBlockIndex}`;
|
||||
textBlockIndex++;
|
||||
// OpenAI requires id to be max 64 characters
|
||||
let msgId = parsedSignature?.id;
|
||||
if (!msgId) {
|
||||
msgId = fallbackMessageId;
|
||||
} else if (msgId.length > 64) {
|
||||
msgId = `msg_${shortHash(msgId)}`;
|
||||
}
|
||||
output.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: sanitizeSurrogates(textBlock.text), annotations: [] }],
|
||||
status: "completed",
|
||||
id: msgId,
|
||||
phase: parsedSignature?.phase,
|
||||
} satisfies ResponseOutputMessage);
|
||||
} else if (block.type === "toolCall") {
|
||||
const toolCall = block as ToolCall;
|
||||
const [callId, itemIdRaw] = toolCall.id.split("|");
|
||||
const customInputProperty = options?.grammarToolInputProperties?.get(toolCall.name);
|
||||
let itemId: string | undefined = itemIdRaw;
|
||||
|
||||
// For different-model messages, set id to undefined to avoid pairing validation.
|
||||
// OpenAI tracks which fc_xxx IDs were paired with rs_xxx reasoning items.
|
||||
// By omitting the id, we avoid triggering that validation (like cross-provider does).
|
||||
// When replaying custom-tool calls as a function_call, also drop non-fc_* ids such as
|
||||
// ctc_* custom-tool ids because function_call item ids must be fc_*.
|
||||
if (
|
||||
(isDifferentModel && itemId?.startsWith("fc_")) ||
|
||||
(customInputProperty === undefined && !itemId?.startsWith("fc_"))
|
||||
) {
|
||||
itemId = undefined;
|
||||
}
|
||||
|
||||
const canReplayNamespace = isSameModel || options?.deferredTools?.has(toolCall.name) === true;
|
||||
|
||||
if (customInputProperty !== undefined) {
|
||||
output.push({
|
||||
type: "custom_tool_call",
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
input: sanitizeSurrogates(
|
||||
getGrammarToolInput(toolCall.name, toolCall.arguments, customInputProperty),
|
||||
),
|
||||
...(canReplayNamespace && toolCall.namespace !== undefined
|
||||
? { namespace: toolCall.namespace }
|
||||
: {}),
|
||||
} satisfies ResponseOutputItem);
|
||||
} else {
|
||||
output.push({
|
||||
type: "function_call",
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name: toolCall.name,
|
||||
arguments: JSON.stringify(toolCall.arguments),
|
||||
...(canReplayNamespace && toolCall.namespace !== undefined
|
||||
? { namespace: toolCall.namespace }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (output.length === 0) continue;
|
||||
messages.push(...output);
|
||||
} else if (msg.role === "toolResult") {
|
||||
const [callId] = msg.toolCallId.split("|");
|
||||
const output = convertToolResultOutput(model, msg.content);
|
||||
|
||||
if (options?.grammarToolInputProperties?.has(msg.toolName)) {
|
||||
messages.push({
|
||||
type: "custom_tool_call_output",
|
||||
call_id: callId,
|
||||
output,
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
type: "function_call_output",
|
||||
call_id: callId,
|
||||
output,
|
||||
});
|
||||
}
|
||||
|
||||
const deferredTools: Tool[] = [];
|
||||
for (const name of msg.addedToolNames ?? []) {
|
||||
const tool = options?.deferredTools?.get(name);
|
||||
if (!tool || loadedToolNames.has(name)) continue;
|
||||
loadedToolNames.add(name);
|
||||
deferredTools.push(tool);
|
||||
}
|
||||
if (deferredTools.length > 0 && options?.deferredToolsMode === "additional-tools") {
|
||||
messages.push({
|
||||
type: "additional_tools",
|
||||
role: "developer",
|
||||
tools: convertResponsesTools(deferredTools, options.toolOptions),
|
||||
} satisfies ResponseInputItem);
|
||||
} else if (deferredTools.length > 0 && options?.deferredToolsMode === "tool-search") {
|
||||
const names = deferredTools.map((tool) => tool.name);
|
||||
const searchCallId = `pi_tool_load_${shortHash(`${msg.toolCallId}:${names.join(",")}`)}`;
|
||||
messages.push({
|
||||
type: "tool_search_call",
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
arguments: { query: names.join(" "), limit: names.length },
|
||||
} satisfies ResponseInputItem);
|
||||
messages.push({
|
||||
type: "tool_search_output",
|
||||
call_id: searchCallId,
|
||||
execution: "client",
|
||||
status: "completed",
|
||||
tools: convertResponsesTools(deferredTools, {
|
||||
...options.toolOptions,
|
||||
deferLoading: true,
|
||||
}),
|
||||
} satisfies ResponseToolSearchOutputItemParam);
|
||||
}
|
||||
}
|
||||
msgIndex++;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool conversion
|
||||
// =============================================================================
|
||||
|
||||
export function convertResponsesTools(tools: readonly Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] {
|
||||
const defaultStrict = options?.strict === undefined ? false : options.strict;
|
||||
const supportsStrictMode = options?.supportsStrictMode ?? true;
|
||||
const supportsOpenAIGrammarTools = options?.supportsOpenAIGrammarTools ?? false;
|
||||
|
||||
return tools.map((tool) => {
|
||||
const grammar = resolveGrammarConstrainedSampling(tool, supportsOpenAIGrammarTools);
|
||||
if (grammar) {
|
||||
return {
|
||||
type: "custom",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
format: {
|
||||
type: "grammar",
|
||||
syntax: grammar.format,
|
||||
definition: grammar.definition,
|
||||
},
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
} satisfies OpenAITool;
|
||||
}
|
||||
|
||||
const constrainedStrict = resolveJsonSchemaStrictSampling(tool, supportsStrictMode);
|
||||
const strict = constrainedStrict ?? defaultStrict;
|
||||
const functionTool: Omit<Extract<OpenAITool, { type: "function" }>, "strict"> & {
|
||||
strict?: Extract<OpenAITool, { type: "function" }>["strict"];
|
||||
} = {
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: getJsonSchemaToolParameters(tool, strict === true) as Record<string, unknown>,
|
||||
...(options?.deferLoading ? { defer_loading: true } : {}),
|
||||
};
|
||||
if (supportsStrictMode) {
|
||||
functionTool.strict = strict;
|
||||
}
|
||||
return functionTool as OpenAITool;
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Stream processing
|
||||
// =============================================================================
|
||||
|
||||
type StreamingToolCall = ToolCall & {
|
||||
partialJson?: string;
|
||||
customInput?: {
|
||||
property: string;
|
||||
jsonBuffer: GrammarToolInputJsonBuffer;
|
||||
};
|
||||
};
|
||||
|
||||
function getCustomToolCallInput(block: StreamingToolCall): string {
|
||||
const property = block.customInput?.property;
|
||||
if (property === undefined) return "";
|
||||
const value = block.arguments[property];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function appendCustomToolCallInput(block: StreamingToolCall, nextInput: string, close: boolean): string | undefined {
|
||||
const customInput = block.customInput;
|
||||
if (!customInput) return undefined;
|
||||
const delta = appendGrammarToolInputJsonDelta(customInput.jsonBuffer, customInput.property, nextInput, close);
|
||||
block.arguments = { [customInput.property]: nextInput };
|
||||
return delta;
|
||||
}
|
||||
|
||||
type ResponsesOutputSlot =
|
||||
| { type: "thinking"; block: ThinkingContent; contentIndex: number }
|
||||
| { type: "text"; block: TextContent; contentIndex: number }
|
||||
| { type: "toolCall"; block: StreamingToolCall; contentIndex: number };
|
||||
|
||||
type ToolCallOutputSlot = Extract<ResponsesOutputSlot, { type: "toolCall" }>;
|
||||
|
||||
export async function processResponsesStream<TApi extends Api>(
|
||||
openaiStream: AsyncIterable<ResponseStreamEvent>,
|
||||
output: AssistantMessage,
|
||||
stream: AssistantMessageEventStream,
|
||||
model: Model<TApi>,
|
||||
options?: OpenAIResponsesStreamOptions,
|
||||
): Promise<void> {
|
||||
let sawTerminalResponseEvent = false;
|
||||
const outputSlots = new Map<number, ResponsesOutputSlot>();
|
||||
const reasoningBlocksById = new Map<string, ThinkingContent>();
|
||||
const applyMessagePhaseStopReason = (item: ResponseOutputItem): void => {
|
||||
if (item.type === "message" && item.phase === "final_answer") {
|
||||
output.stopReason = "stop";
|
||||
}
|
||||
};
|
||||
const getSlot = <TType extends ResponsesOutputSlot["type"]>(
|
||||
outputIndex: number,
|
||||
type: TType,
|
||||
): Extract<ResponsesOutputSlot, { type: TType }> | undefined => {
|
||||
const slot = outputSlots.get(outputIndex);
|
||||
return slot?.type === type ? (slot as Extract<ResponsesOutputSlot, { type: TType }>) : undefined;
|
||||
};
|
||||
const pushToolCallDelta = (slot: ToolCallOutputSlot, delta: string | undefined): void => {
|
||||
if (delta === undefined) return;
|
||||
stream.push({
|
||||
type: "toolcall_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta,
|
||||
partial: output,
|
||||
});
|
||||
};
|
||||
const createSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
|
||||
if (item.type === "reasoning") {
|
||||
const block: ThinkingContent = { type: "thinking", thinking: "" };
|
||||
output.content.push(block);
|
||||
const slot = {
|
||||
type: "thinking",
|
||||
block,
|
||||
contentIndex: output.content.length - 1,
|
||||
} satisfies ResponsesOutputSlot;
|
||||
outputSlots.set(outputIndex, slot);
|
||||
stream.push({ type: "thinking_start", contentIndex: slot.contentIndex, partial: output });
|
||||
return slot;
|
||||
}
|
||||
if (item.type === "message") {
|
||||
applyMessagePhaseStopReason(item);
|
||||
const block: TextContent = { type: "text", text: "" };
|
||||
output.content.push(block);
|
||||
const slot = { type: "text", block, contentIndex: output.content.length - 1 } satisfies ResponsesOutputSlot;
|
||||
outputSlots.set(outputIndex, slot);
|
||||
stream.push({ type: "text_start", contentIndex: slot.contentIndex, partial: output });
|
||||
return slot;
|
||||
}
|
||||
if (item.type === "function_call") {
|
||||
const block: StreamingToolCall = {
|
||||
type: "toolCall",
|
||||
id: `${item.call_id}|${item.id}`,
|
||||
name: item.name,
|
||||
arguments: {},
|
||||
...(item.namespace !== undefined ? { namespace: item.namespace } : {}),
|
||||
partialJson: item.arguments || "",
|
||||
};
|
||||
output.content.push(block);
|
||||
const slot = {
|
||||
type: "toolCall",
|
||||
block,
|
||||
contentIndex: output.content.length - 1,
|
||||
} satisfies ResponsesOutputSlot;
|
||||
outputSlots.set(outputIndex, slot);
|
||||
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
||||
return slot;
|
||||
}
|
||||
if (item.type === "custom_tool_call") {
|
||||
const inputProperty = options?.grammarToolInputProperties?.get(item.name) ?? "input";
|
||||
const input = item.input || "";
|
||||
const block: StreamingToolCall = {
|
||||
type: "toolCall",
|
||||
id: `${item.call_id}|${item.id}`,
|
||||
name: item.name,
|
||||
arguments: { [inputProperty]: input },
|
||||
...(item.namespace !== undefined ? { namespace: item.namespace } : {}),
|
||||
customInput: {
|
||||
property: inputProperty,
|
||||
jsonBuffer: { input: "", started: false, closed: false },
|
||||
},
|
||||
};
|
||||
output.content.push(block);
|
||||
const slot = {
|
||||
type: "toolCall",
|
||||
block,
|
||||
contentIndex: output.content.length - 1,
|
||||
} satisfies ResponsesOutputSlot;
|
||||
outputSlots.set(outputIndex, slot);
|
||||
stream.push({ type: "toolcall_start", contentIndex: slot.contentIndex, partial: output });
|
||||
return slot;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const getOrCreateSlot = (outputIndex: number, item: ResponseOutputItem): ResponsesOutputSlot | undefined => {
|
||||
return outputSlots.get(outputIndex) ?? createSlot(outputIndex, item);
|
||||
};
|
||||
// Azure OpenAI can omit reasoning.encrypted_content from response.output_item.done
|
||||
// and provide it only in response.completed.response.output. Backfill the
|
||||
// persisted reasoning signature from the terminal response to keep store:false
|
||||
// multi-turn replay stateless. See https://github.com/earendil-works/pi/issues/6409.
|
||||
const backfillReasoningSignatures = (responseOutput: ResponseOutputItem[]): void => {
|
||||
for (const item of responseOutput) {
|
||||
if (item.type !== "reasoning" || !item.encrypted_content) continue;
|
||||
const block = reasoningBlocksById.get(item.id);
|
||||
if (!block?.thinkingSignature) continue;
|
||||
|
||||
const storedItem = JSON.parse(block.thinkingSignature) as ResponseReasoningItem;
|
||||
if (storedItem.encrypted_content) continue;
|
||||
block.thinkingSignature = JSON.stringify({
|
||||
...storedItem,
|
||||
encrypted_content: item.encrypted_content,
|
||||
});
|
||||
}
|
||||
};
|
||||
const finalizeResponse = (
|
||||
response: Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>["response"],
|
||||
): void => {
|
||||
sawTerminalResponseEvent = true;
|
||||
backfillReasoningSignatures(response.output ?? []);
|
||||
if (response?.id) {
|
||||
output.responseId = response.id;
|
||||
}
|
||||
if (response?.usage) {
|
||||
const inputDetails = response.usage.input_tokens_details as
|
||||
| { cached_tokens?: number; cache_write_tokens?: number }
|
||||
| undefined;
|
||||
const cachedTokens = inputDetails?.cached_tokens || 0;
|
||||
const cacheWriteTokens = inputDetails?.cache_write_tokens || 0;
|
||||
output.usage = {
|
||||
// OpenAI includes cached and cache-write tokens in input_tokens, so subtract both.
|
||||
input: Math.max(0, (response.usage.input_tokens || 0) - cachedTokens - cacheWriteTokens),
|
||||
output: response.usage.output_tokens || 0,
|
||||
cacheRead: cachedTokens,
|
||||
cacheWrite: cacheWriteTokens,
|
||||
reasoning: response.usage.output_tokens_details?.reasoning_tokens || 0,
|
||||
totalTokens: response.usage.total_tokens || 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
calculateCost(model, output.usage);
|
||||
if (options?.applyServiceTierPricing) {
|
||||
const serviceTier = options.resolveServiceTier
|
||||
? options.resolveServiceTier(response?.service_tier, options.serviceTier)
|
||||
: (response?.service_tier ?? options.serviceTier);
|
||||
options.applyServiceTierPricing(output.usage, serviceTier);
|
||||
}
|
||||
// Map status to stop reason. For incomplete responses, retain the provider's
|
||||
// specific reason so max-output truncation and content filtering stay distinct.
|
||||
const status = response?.status;
|
||||
const incompleteDetails = response?.incomplete_details as { reason?: unknown } | null | undefined;
|
||||
const incompleteReason = typeof incompleteDetails?.reason === "string" ? incompleteDetails.reason : undefined;
|
||||
output.rawStopReason = incompleteReason ? `${status}.${incompleteReason}` : status;
|
||||
const mappedStop = mapStopReason(status, incompleteReason);
|
||||
output.stopReason = mappedStop.stopReason;
|
||||
if (mappedStop.errorMessage === undefined) delete output.errorMessage;
|
||||
else output.errorMessage = mappedStop.errorMessage;
|
||||
if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") {
|
||||
output.stopReason = "toolUse";
|
||||
}
|
||||
};
|
||||
|
||||
for await (const event of openaiStream) {
|
||||
if (event.type === "response.created") {
|
||||
output.responseId = event.response.id;
|
||||
} else if (event.type === "response.output_item.added") {
|
||||
createSlot(event.output_index, event.item);
|
||||
} else if (event.type === "response.reasoning_summary_text.delta") {
|
||||
const slot = getSlot(event.output_index, "thinking");
|
||||
if (!slot) continue;
|
||||
slot.block.thinking += event.delta;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta: event.delta,
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.type === "response.reasoning_summary_part.done") {
|
||||
const slot = getSlot(event.output_index, "thinking");
|
||||
if (!slot) continue;
|
||||
slot.block.thinking += "\n\n";
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta: "\n\n",
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.type === "response.reasoning_text.delta") {
|
||||
const slot = getSlot(event.output_index, "thinking");
|
||||
if (!slot) continue;
|
||||
slot.block.thinking += event.delta;
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta: event.delta,
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.type === "response.output_text.delta") {
|
||||
const slot = getSlot(event.output_index, "text");
|
||||
if (!slot) continue;
|
||||
slot.block.text += event.delta;
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta: event.delta,
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.type === "response.refusal.delta") {
|
||||
const slot = getSlot(event.output_index, "text");
|
||||
if (!slot) continue;
|
||||
slot.block.text += event.delta;
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: slot.contentIndex,
|
||||
delta: event.delta,
|
||||
partial: output,
|
||||
});
|
||||
} else if (event.type === "response.function_call_arguments.delta") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot || slot.block.partialJson === undefined) continue;
|
||||
slot.block.partialJson += event.delta;
|
||||
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
|
||||
pushToolCallDelta(slot, event.delta);
|
||||
} else if (event.type === "response.function_call_arguments.done") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot || slot.block.partialJson === undefined) continue;
|
||||
const previousPartialJson = slot.block.partialJson;
|
||||
slot.block.partialJson = event.arguments;
|
||||
slot.block.arguments = parseStreamingJson(slot.block.partialJson);
|
||||
|
||||
if (event.arguments.startsWith(previousPartialJson)) {
|
||||
const delta = event.arguments.slice(previousPartialJson.length);
|
||||
if (delta.length > 0) pushToolCallDelta(slot, delta);
|
||||
}
|
||||
} else if (event.type === "response.custom_tool_call_input.delta") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot || !slot.block.customInput) continue;
|
||||
pushToolCallDelta(
|
||||
slot,
|
||||
appendCustomToolCallInput(slot.block, getCustomToolCallInput(slot.block) + event.delta, false),
|
||||
);
|
||||
} else if (event.type === "response.custom_tool_call_input.done") {
|
||||
const slot = getSlot(event.output_index, "toolCall");
|
||||
if (!slot || !slot.block.customInput) continue;
|
||||
pushToolCallDelta(slot, appendCustomToolCallInput(slot.block, event.input, true));
|
||||
} else if (event.type === "response.output_item.done") {
|
||||
const item = event.item;
|
||||
applyMessagePhaseStopReason(item);
|
||||
const slot = getOrCreateSlot(event.output_index, item);
|
||||
|
||||
if (item.type === "reasoning" && slot?.type === "thinking") {
|
||||
const summaryText = item.summary?.map((s) => s.text).join("\n\n") || "";
|
||||
const contentText = item.content?.map((c) => c.text).join("\n\n") || "";
|
||||
slot.block.thinking = summaryText || contentText || slot.block.thinking;
|
||||
slot.block.thinkingSignature = JSON.stringify(item);
|
||||
reasoningBlocksById.set(item.id, slot.block);
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: slot.contentIndex,
|
||||
content: slot.block.thinking,
|
||||
partial: output,
|
||||
});
|
||||
outputSlots.delete(event.output_index);
|
||||
} else if (item.type === "message" && slot?.type === "text") {
|
||||
slot.block.text = item.content?.map((c) => (c.type === "output_text" ? c.text : c.refusal)).join("") || "";
|
||||
slot.block.textSignature = encodeTextSignatureV1(item.id, item.phase ?? undefined);
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: slot.contentIndex,
|
||||
content: slot.block.text,
|
||||
partial: output,
|
||||
});
|
||||
outputSlots.delete(event.output_index);
|
||||
} else if (
|
||||
item.type === "function_call" &&
|
||||
slot?.type === "toolCall" &&
|
||||
slot.block.partialJson !== undefined
|
||||
) {
|
||||
slot.block.arguments = parseStreamingJson(item.arguments || slot.block.partialJson || "{}");
|
||||
if (item.namespace !== undefined) slot.block.namespace = item.namespace;
|
||||
// Finalize in-place and strip the scratch buffer so replay only
|
||||
// carries parsed arguments.
|
||||
delete slot.block.partialJson;
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: slot.contentIndex,
|
||||
toolCall: slot.block,
|
||||
partial: output,
|
||||
});
|
||||
outputSlots.delete(event.output_index);
|
||||
} else if (item.type === "custom_tool_call" && slot?.type === "toolCall" && slot.block.customInput) {
|
||||
pushToolCallDelta(
|
||||
slot,
|
||||
appendCustomToolCallInput(slot.block, item.input ?? getCustomToolCallInput(slot.block), true),
|
||||
);
|
||||
if (item.namespace !== undefined) slot.block.namespace = item.namespace;
|
||||
delete slot.block.customInput;
|
||||
stream.push({
|
||||
type: "toolcall_end",
|
||||
contentIndex: slot.contentIndex,
|
||||
toolCall: slot.block,
|
||||
partial: output,
|
||||
});
|
||||
outputSlots.delete(event.output_index);
|
||||
}
|
||||
} else if (event.type === "response.completed" || event.type === "response.incomplete") {
|
||||
finalizeResponse(event.response);
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(`Error Code ${event.code}: ${event.message}` || "Unknown error");
|
||||
} else if (event.type === "response.failed") {
|
||||
sawTerminalResponseEvent = true;
|
||||
output.rawStopReason = event.response?.status;
|
||||
const error = event.response?.error;
|
||||
const details = event.response?.incomplete_details;
|
||||
const msg = error
|
||||
? `${error.code || "unknown"}: ${error.message || "no message"}`
|
||||
: details?.reason
|
||||
? `incomplete: ${details.reason}`
|
||||
: "Unknown error (no error details in response)";
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
if (!sawTerminalResponseEvent) {
|
||||
throw new Error("OpenAI Responses stream ended before a terminal response event");
|
||||
}
|
||||
}
|
||||
|
||||
function mapStopReason(
|
||||
status: OpenAI.Responses.ResponseStatus | undefined,
|
||||
incompleteReason?: string,
|
||||
): { stopReason: StopReason; errorMessage?: string } {
|
||||
if (!status) return { stopReason: "stop" };
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return { stopReason: "stop" };
|
||||
case "incomplete":
|
||||
if (incompleteReason === "max_output_tokens") {
|
||||
return { stopReason: "length" };
|
||||
}
|
||||
return {
|
||||
stopReason: "error",
|
||||
errorMessage: incompleteReason
|
||||
? `Response incomplete: ${incompleteReason}`
|
||||
: "Response incomplete without a provider reason",
|
||||
};
|
||||
case "failed":
|
||||
case "cancelled":
|
||||
return { stopReason: "error" };
|
||||
// These two are wonky ...
|
||||
case "in_progress":
|
||||
case "queued":
|
||||
return { stopReason: "stop" };
|
||||
default: {
|
||||
const _exhaustive: never = status;
|
||||
throw new Error(`Unhandled stop reason: ${_exhaustive}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
4
packages/ai/src/api/openai-responses.lazy.ts
Normal file
4
packages/ai/src/api/openai-responses.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const openAIResponsesApi = (): ProviderStreams => lazyApi(() => import("./openai-responses.ts"));
|
||||
390
packages/ai/src/api/openai-responses.ts
Normal file
390
packages/ai/src/api/openai-responses.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import OpenAI from "openai";
|
||||
import type { ResponseCreateParamsStreaming } from "openai/resources/responses/responses.js";
|
||||
import { clampThinkingLevel } from "../models.ts";
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
CacheRetention,
|
||||
Context,
|
||||
Model,
|
||||
OpenAIResponsesCompat,
|
||||
ProviderEnv,
|
||||
ProviderHeaders,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
Usage,
|
||||
} from "../types.ts";
|
||||
import { splitDeferredTools } from "../utils/deferred-tools.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord } from "../utils/headers.ts";
|
||||
import { getPiUserAgent } from "../utils/pi-user-agent.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { createGrammarToolInputProperties } from "./constrained-sampling.ts";
|
||||
import { buildCopilotDynamicHeaders, hasCopilotVisionInput } from "./github-copilot-headers.ts";
|
||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.ts";
|
||||
import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.ts";
|
||||
import { buildBaseOptions } from "./simple-options.ts";
|
||||
|
||||
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
||||
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
|
||||
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
|
||||
|
||||
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
|
||||
if (!headers) return false;
|
||||
const expected = name.toLowerCase();
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.toLowerCase() === expected && value !== null && value.trim().length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getClientApiKey(provider: string, apiKey: string | undefined, headers: ProviderHeaders | undefined): string {
|
||||
if (apiKey) return apiKey;
|
||||
if (hasHeader(headers, "authorization") || hasHeader(headers, "cf-aig-authorization")) return "unused";
|
||||
throw new Error(`No API key for provider: ${provider}`);
|
||||
}
|
||||
|
||||
function detectSessionAffinityFormat(model: Pick<Model<"openai-responses">, "provider" | "baseUrl">) {
|
||||
return model.provider === "openrouter" || model.baseUrl.includes("openrouter.ai") ? "openrouter" : "openai";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve cache retention preference.
|
||||
* Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
|
||||
*/
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
if (getProviderEnvValue("PI_CACHE_RETENTION", env) === "long") {
|
||||
return "long";
|
||||
}
|
||||
return "short";
|
||||
}
|
||||
|
||||
function getCompat(model: Model<"openai-responses">): Required<OpenAIResponsesCompat> {
|
||||
return {
|
||||
supportsDeveloperRole: model.compat?.supportsDeveloperRole ?? true,
|
||||
sessionAffinityFormat: model.compat?.sessionAffinityFormat ?? detectSessionAffinityFormat(model),
|
||||
supportsLongCacheRetention: model.compat?.supportsLongCacheRetention ?? true,
|
||||
supportsStrictMode: model.compat?.supportsStrictMode ?? false,
|
||||
supportsOpenAIGrammarTools: model.compat?.supportsOpenAIGrammarTools ?? false,
|
||||
supportsAdditionalTools: model.compat?.supportsAdditionalTools ?? false,
|
||||
supportsToolSearch: model.compat?.supportsToolSearch ?? false,
|
||||
supportsExplicitPromptCacheMode: model.compat?.supportsExplicitPromptCacheMode ?? false,
|
||||
supportsMaxOutputTokens: model.compat?.supportsMaxOutputTokens ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function getPromptCacheRetention(
|
||||
compat: Required<OpenAIResponsesCompat>,
|
||||
cacheRetention: CacheRetention,
|
||||
): "24h" | undefined {
|
||||
return cacheRetention === "long" && compat.supportsLongCacheRetention && !compat.supportsExplicitPromptCacheMode
|
||||
? "24h"
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function getPromptCacheOptions(
|
||||
compat: Required<OpenAIResponsesCompat>,
|
||||
cacheRetention: CacheRetention,
|
||||
): { mode?: "explicit"; ttl?: "30m" } | undefined {
|
||||
if (!compat.supportsExplicitPromptCacheMode) return undefined;
|
||||
if (cacheRetention === "none") return { mode: "explicit" };
|
||||
if (cacheRetention === "long" && compat.supportsLongCacheRetention) return { ttl: "30m" };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatOpenAIResponsesError(error: unknown): string {
|
||||
return formatProviderError(normalizeProviderError(error), "OpenAI API error");
|
||||
}
|
||||
|
||||
// OpenAI Responses-specific options
|
||||
export interface OpenAIResponsesOptions extends StreamOptions {
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
||||
reasoningSummary?: "auto" | "detailed" | "concise" | null;
|
||||
serviceTier?: ResponseCreateParamsStreaming["service_tier"];
|
||||
toolChoice?: ResponseCreateParamsStreaming["tool_choice"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate function for OpenAI Responses API
|
||||
*/
|
||||
export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions> = (
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
options?: OpenAIResponsesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const stream = new AssistantMessageEventStream();
|
||||
|
||||
// Start async processing
|
||||
(async () => {
|
||||
const output: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api as Api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Create OpenAI client
|
||||
const apiKey = getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId;
|
||||
const compat = getCompat(model);
|
||||
const grammarToolInputProperties = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
);
|
||||
const client = createClient(model, context, apiKey, options?.headers, options?.fetch, cacheSessionId);
|
||||
let params = buildParams(model, context, options, compat, grammarToolInputProperties);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as ResponseCreateParamsStreaming;
|
||||
}
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: openaiStream, response } = await retryProviderRequest(
|
||||
() => client.responses.create(params, requestOptions).withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
await processResponsesStream(openaiStream, output, stream, model, {
|
||||
serviceTier: options?.serviceTier,
|
||||
grammarToolInputProperties,
|
||||
applyServiceTierPricing: (usage, serviceTier) => applyServiceTierPricing(usage, serviceTier, model),
|
||||
});
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
throw new Error("Request was aborted");
|
||||
}
|
||||
|
||||
if (output.stopReason === "pending") {
|
||||
throw new Error("OpenAI Responses stream ended without a stop reason");
|
||||
}
|
||||
if (output.stopReason === "aborted" || output.stopReason === "error") {
|
||||
throw new Error(output.errorMessage || "An unknown error occurred");
|
||||
}
|
||||
|
||||
stream.push({ type: "done", reason: output.stopReason, message: output });
|
||||
stream.end();
|
||||
} catch (error) {
|
||||
for (const block of output.content) {
|
||||
delete (block as { index?: number }).index;
|
||||
// Streaming scratch buffers are only used during parsing; never persist them.
|
||||
delete (block as { partialJson?: string }).partialJson;
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatOpenAIResponsesError(error);
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
})();
|
||||
|
||||
return stream;
|
||||
};
|
||||
|
||||
export const streamSimple: StreamFunction<"openai-responses", SimpleStreamOptions> = (
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
getClientApiKey(model.provider, options?.apiKey, options?.headers);
|
||||
|
||||
const base = {
|
||||
...buildBaseOptions(model, context, options, options?.apiKey),
|
||||
toolChoice: options?.toolChoice,
|
||||
} satisfies OpenAIResponsesOptions;
|
||||
const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
|
||||
const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
|
||||
|
||||
return stream(model, context, {
|
||||
...base,
|
||||
reasoningEffort,
|
||||
} satisfies OpenAIResponsesOptions);
|
||||
};
|
||||
|
||||
function createClient(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
sessionId?: string,
|
||||
) {
|
||||
const compat = getCompat(model);
|
||||
const headers: ProviderHeaders = { "User-Agent": getPiUserAgent(), ...model.headers };
|
||||
if (model.provider === "github-copilot") {
|
||||
const hasImages = hasCopilotVisionInput(context.messages);
|
||||
const copilotHeaders = buildCopilotDynamicHeaders({
|
||||
messages: context.messages,
|
||||
hasImages,
|
||||
});
|
||||
Object.assign(headers, copilotHeaders);
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
if (compat.sessionAffinityFormat === "openrouter") {
|
||||
headers["x-session-id"] = sessionId;
|
||||
} else {
|
||||
if (compat.sessionAffinityFormat === "openai") {
|
||||
headers.session_id = sessionId;
|
||||
}
|
||||
headers["x-client-request-id"] = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge options headers last so they can override defaults
|
||||
if (optionsHeaders) {
|
||||
Object.assign(headers, optionsHeaders);
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetch,
|
||||
defaultHeaders: headers,
|
||||
});
|
||||
}
|
||||
|
||||
function buildParams(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
options: OpenAIResponsesOptions | undefined,
|
||||
compat: Required<OpenAIResponsesCompat> = getCompat(model),
|
||||
grammarToolInputProperties: ReadonlyMap<string, string> = createGrammarToolInputProperties(
|
||||
context.tools,
|
||||
compat.supportsOpenAIGrammarTools,
|
||||
),
|
||||
) {
|
||||
const deferredToolsMode = compat.supportsAdditionalTools
|
||||
? "additional-tools"
|
||||
: compat.supportsToolSearch
|
||||
? "tool-search"
|
||||
: undefined;
|
||||
const toolPlacement = splitDeferredTools(context, deferredToolsMode !== undefined);
|
||||
const messages = convertResponsesMessages(model, context, OPENAI_TOOL_CALL_PROVIDERS, {
|
||||
grammarToolInputProperties,
|
||||
deferredTools: toolPlacement.deferred,
|
||||
deferredToolsMode,
|
||||
toolOptions: {
|
||||
supportsStrictMode: compat.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
||||
},
|
||||
});
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
const params: ResponseCreateParamsStreaming & {
|
||||
prompt_cache_options?: { mode?: "explicit"; ttl?: "30m" };
|
||||
} = {
|
||||
model: model.id,
|
||||
input: messages,
|
||||
stream: true,
|
||||
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
|
||||
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
|
||||
prompt_cache_options: getPromptCacheOptions(compat, cacheRetention),
|
||||
store: false,
|
||||
};
|
||||
|
||||
if (options?.maxTokens && compat.supportsMaxOutputTokens) {
|
||||
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
|
||||
}
|
||||
|
||||
if (options?.temperature !== undefined) {
|
||||
params.temperature = options?.temperature;
|
||||
}
|
||||
|
||||
if (options?.serviceTier !== undefined) {
|
||||
params.service_tier = options.serviceTier;
|
||||
}
|
||||
|
||||
if (toolPlacement.immediate.length > 0) {
|
||||
params.tools = convertResponsesTools(toolPlacement.immediate, {
|
||||
supportsStrictMode: compat.supportsStrictMode,
|
||||
supportsOpenAIGrammarTools: compat.supportsOpenAIGrammarTools,
|
||||
});
|
||||
}
|
||||
|
||||
if (options?.toolChoice !== undefined) {
|
||||
params.tool_choice = options.toolChoice;
|
||||
}
|
||||
|
||||
if (model.reasoning) {
|
||||
if (options?.reasoningEffort || options?.reasoningSummary) {
|
||||
const effort = options?.reasoningEffort
|
||||
? (model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort)
|
||||
: "medium";
|
||||
params.reasoning = {
|
||||
effort: effort as NonNullable<typeof params.reasoning>["effort"],
|
||||
summary: options?.reasoningSummary || "auto",
|
||||
};
|
||||
params.include = ["reasoning.encrypted_content"];
|
||||
} else if (model.provider !== "github-copilot" && model.thinkingLevelMap?.off !== null) {
|
||||
params.reasoning = {
|
||||
effort: (model.thinkingLevelMap?.off ?? "none") as NonNullable<typeof params.reasoning>["effort"],
|
||||
};
|
||||
}
|
||||
if (model.provider === "xai") params.include = ["reasoning.encrypted_content"];
|
||||
}
|
||||
|
||||
// Last so custom keys override the named request fields.
|
||||
if (options?.samplingParams) {
|
||||
Object.assign(params, options.samplingParams);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function getServiceTierCostMultiplier(
|
||||
model: Pick<Model<"openai-responses">, "id">,
|
||||
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
): number {
|
||||
switch (serviceTier) {
|
||||
case "flex":
|
||||
return 0.5;
|
||||
case "priority":
|
||||
return model.id === "gpt-5.5" ? 2.5 : 2;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function applyServiceTierPricing(
|
||||
usage: Usage,
|
||||
serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined,
|
||||
model: Pick<Model<"openai-responses">, "id">,
|
||||
) {
|
||||
const multiplier = getServiceTierCostMultiplier(model, serviceTier);
|
||||
if (multiplier === 1) return;
|
||||
|
||||
usage.cost.input *= multiplier;
|
||||
usage.cost.output *= multiplier;
|
||||
usage.cost.cacheRead *= multiplier;
|
||||
usage.cost.cacheWrite *= multiplier;
|
||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||
}
|
||||
10
packages/ai/src/api/openrouter-images.lazy.ts
Normal file
10
packages/ai/src/api/openrouter-images.lazy.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ImagesModel, ProviderImages } from "../types.ts";
|
||||
|
||||
export const openrouterImagesApi = (): ProviderImages => ({
|
||||
generateImages: async (model, context, options) =>
|
||||
(await import("./openrouter-images.ts")).generateImages(
|
||||
model as ImagesModel<"openrouter-images">,
|
||||
context,
|
||||
options,
|
||||
),
|
||||
});
|
||||
196
packages/ai/src/api/openrouter-images.ts
Normal file
196
packages/ai/src/api/openrouter-images.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import OpenAI from "openai";
|
||||
import type {
|
||||
ChatCompletion,
|
||||
ChatCompletionContentPart,
|
||||
ChatCompletionContentPartImage,
|
||||
ChatCompletionContentPartText,
|
||||
ChatCompletionCreateParamsNonStreaming,
|
||||
} from "openai/resources/chat/completions.js";
|
||||
import type {
|
||||
AssistantImages,
|
||||
ImageContent,
|
||||
ImagesContext,
|
||||
ImagesFunction,
|
||||
ImagesModel,
|
||||
ImagesOptions,
|
||||
ProviderHeaders,
|
||||
TextContent,
|
||||
} from "../types.ts";
|
||||
import { formatProviderError, normalizeProviderError } from "../utils/error-body.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { retryProviderRequest } from "../utils/provider-retry.ts";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.ts";
|
||||
|
||||
interface OpenRouterGeneratedImage {
|
||||
image_url?: string | { url?: string };
|
||||
}
|
||||
|
||||
type OpenRouterImageGenerationMessage = ChatCompletion["choices"][number]["message"] & {
|
||||
images?: OpenRouterGeneratedImage[];
|
||||
};
|
||||
|
||||
type OpenRouterImageGenerationChoice = ChatCompletion["choices"][number] & {
|
||||
message: OpenRouterImageGenerationMessage;
|
||||
};
|
||||
|
||||
type OpenRouterImageGenerationResponse = ChatCompletion & {
|
||||
choices: OpenRouterImageGenerationChoice[];
|
||||
};
|
||||
|
||||
export const generateImages: ImagesFunction<"openrouter-images", ImagesOptions> = async (
|
||||
model: ImagesModel<"openrouter-images">,
|
||||
context: ImagesContext,
|
||||
options?: ImagesOptions,
|
||||
) => {
|
||||
const output: AssistantImages = {
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
output: [],
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key for provider: ${model.provider}`);
|
||||
}
|
||||
const client = createClient(model, apiKey, options?.headers, options?.fetch);
|
||||
let params = buildParams(model, context);
|
||||
const nextParams = await options?.onPayload?.(params, model);
|
||||
if (nextParams !== undefined) {
|
||||
params = nextParams as typeof params;
|
||||
}
|
||||
const requestOptions = {
|
||||
...(options?.signal ? { signal: options.signal } : {}),
|
||||
...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
||||
maxRetries: 0,
|
||||
};
|
||||
const { data: response, response: rawResponse } = await retryProviderRequest(
|
||||
() =>
|
||||
client.chat.completions
|
||||
.create(params as unknown as ChatCompletionCreateParamsNonStreaming, requestOptions)
|
||||
.withResponse(),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
signal: options?.signal,
|
||||
},
|
||||
);
|
||||
await options?.onResponse?.({ status: rawResponse.status, headers: headersToRecord(rawResponse.headers) }, model);
|
||||
|
||||
const imageResponse = response as OpenRouterImageGenerationResponse;
|
||||
output.responseId = imageResponse.id;
|
||||
if (imageResponse.usage) {
|
||||
output.usage = parseUsage(imageResponse.usage, model);
|
||||
}
|
||||
|
||||
const choice = imageResponse.choices[0];
|
||||
if (choice) {
|
||||
const content = choice.message.content;
|
||||
if (typeof content === "string" && content.length > 0) {
|
||||
output.output.push({ type: "text", text: content } satisfies TextContent);
|
||||
}
|
||||
|
||||
for (const image of choice.message.images ?? []) {
|
||||
const imageUrl = typeof image.image_url === "string" ? image.image_url : image.image_url?.url;
|
||||
if (!imageUrl?.startsWith("data:")) continue;
|
||||
const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!matches) continue;
|
||||
output.output.push({
|
||||
type: "image",
|
||||
mimeType: matches[1],
|
||||
data: matches[2],
|
||||
} satisfies ImageContent);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
} catch (error) {
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatProviderError(normalizeProviderError(error));
|
||||
return output;
|
||||
}
|
||||
};
|
||||
|
||||
function createClient(
|
||||
model: ImagesModel<"openrouter-images">,
|
||||
apiKey: string,
|
||||
optionsHeaders?: ProviderHeaders,
|
||||
fetch?: typeof globalThis.fetch,
|
||||
): OpenAI {
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: model.baseUrl,
|
||||
dangerouslyAllowBrowser: true,
|
||||
fetch,
|
||||
defaultHeaders: providerHeadersToRecord({ ...model.headers, ...optionsHeaders }),
|
||||
});
|
||||
}
|
||||
|
||||
type OpenRouterImagesCreateParams = Omit<ChatCompletionCreateParamsNonStreaming, "modalities"> & {
|
||||
modalities: Array<"image" | "text">;
|
||||
};
|
||||
|
||||
function buildParams(model: ImagesModel<"openrouter-images">, context: ImagesContext): OpenRouterImagesCreateParams {
|
||||
const content: ChatCompletionContentPart[] = context.input.map((item): ChatCompletionContentPart => {
|
||||
if (item.type === "text") {
|
||||
return {
|
||||
type: "text",
|
||||
text: sanitizeSurrogates(item.text),
|
||||
} satisfies ChatCompletionContentPartText;
|
||||
}
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${item.mimeType};base64,${item.data}`,
|
||||
},
|
||||
} satisfies ChatCompletionContentPartImage;
|
||||
});
|
||||
|
||||
return {
|
||||
model: model.id,
|
||||
messages: [
|
||||
{
|
||||
role: "user" as const,
|
||||
content,
|
||||
},
|
||||
],
|
||||
stream: false,
|
||||
modalities: model.output.includes("text") ? ["image", "text"] : ["image"],
|
||||
};
|
||||
}
|
||||
|
||||
function parseUsage(
|
||||
rawUsage: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
|
||||
},
|
||||
model: ImagesModel<"openrouter-images">,
|
||||
) {
|
||||
const promptTokens = rawUsage.prompt_tokens || 0;
|
||||
const reportedCachedTokens = rawUsage.prompt_tokens_details?.cached_tokens || 0;
|
||||
const cacheWriteTokens = rawUsage.prompt_tokens_details?.cache_write_tokens || 0;
|
||||
const cacheReadTokens =
|
||||
cacheWriteTokens > 0 ? Math.max(0, reportedCachedTokens - cacheWriteTokens) : reportedCachedTokens;
|
||||
const input = Math.max(0, promptTokens - cacheReadTokens - cacheWriteTokens);
|
||||
const output = rawUsage.completion_tokens || 0;
|
||||
const usage = {
|
||||
input,
|
||||
output,
|
||||
cacheRead: cacheReadTokens,
|
||||
cacheWrite: cacheWriteTokens,
|
||||
totalTokens: input + output + cacheReadTokens + cacheWriteTokens,
|
||||
cost: {
|
||||
input: (model.cost.input / 1000000) * input,
|
||||
output: (model.cost.output / 1000000) * output,
|
||||
cacheRead: (model.cost.cacheRead / 1000000) * cacheReadTokens,
|
||||
cacheWrite: (model.cost.cacheWrite / 1000000) * cacheWriteTokens,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||
return usage;
|
||||
}
|
||||
4
packages/ai/src/api/pi-messages.lazy.ts
Normal file
4
packages/ai/src/api/pi-messages.lazy.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { ProviderStreams } from "../types.ts";
|
||||
import { lazyApi } from "./lazy.ts";
|
||||
|
||||
export const piMessagesApi = (): ProviderStreams => lazyApi(() => import("./pi-messages.ts"));
|
||||
441
packages/ai/src/api/pi-messages.ts
Normal file
441
packages/ai/src/api/pi-messages.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
/**
|
||||
* pi-messages API implementation.
|
||||
*
|
||||
* Streams pi's own message protocol directly to a backend: the request is a
|
||||
* single POST of `{ model, context, options }` to `<baseUrl>/messages`, the
|
||||
* response is an SSE stream of serialized assistant-message events plus a
|
||||
* terminal `done`/`error` event. This is the wire protocol spoken by the
|
||||
* Radius gateway, but any backend implementing it can be used, e.g. via a
|
||||
* models.json custom provider with `"api": "pi-messages"`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
AssistantMessageEvent,
|
||||
CacheRetention,
|
||||
Context,
|
||||
Model,
|
||||
ProviderEnv,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
ThinkingLevel,
|
||||
ToolCall,
|
||||
} from "../types.ts";
|
||||
import { appendAssistantMessageDiagnostic, createAssistantMessageDiagnostic } from "../utils/diagnostics.ts";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.ts";
|
||||
import { headersToRecord, providerHeadersToRecord } from "../utils/headers.ts";
|
||||
import { parseStreamingJson } from "../utils/json-parse.ts";
|
||||
import { getProviderEnvValue } from "../utils/provider-env.ts";
|
||||
|
||||
export interface PiMessagesOptions extends StreamOptions {
|
||||
reasoning?: ThinkingLevel;
|
||||
toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } };
|
||||
/** Ask the backend for debug metadata (e.g. routing response headers). */
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
type PiMessagesUsage = AssistantMessage["usage"];
|
||||
type PiMessagesStopReason = AssistantMessage["stopReason"];
|
||||
|
||||
/** Impact summary of a server-side message rewrite (e.g. a gateway policy). */
|
||||
export type PiMessagesRewriteImpact = {
|
||||
policyId: string;
|
||||
policyVersion: number;
|
||||
changed: boolean;
|
||||
tokenCountChange: number;
|
||||
messageCountChange: number;
|
||||
systemPromptChanged: boolean;
|
||||
};
|
||||
|
||||
/** Serialized assistant-message event as sent by a pi-messages backend. */
|
||||
export type PiMessagesEvent =
|
||||
| { type: "start" }
|
||||
| { type: "text_start"; contentIndex: number }
|
||||
| { type: "text_delta"; contentIndex: number; delta: string }
|
||||
| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }
|
||||
| { type: "thinking_start"; contentIndex: number }
|
||||
| { type: "thinking_delta"; contentIndex: number; delta: string }
|
||||
| {
|
||||
type: "thinking_end";
|
||||
contentIndex: number;
|
||||
content: string;
|
||||
contentSignature?: string;
|
||||
redacted?: boolean;
|
||||
}
|
||||
| { type: "toolcall_start"; contentIndex: number; id: string; toolName: string }
|
||||
| { type: "toolcall_delta"; contentIndex: number; delta: string }
|
||||
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall }
|
||||
| {
|
||||
type: "done";
|
||||
reason: Extract<PiMessagesStopReason, "stop" | "length" | "toolUse">;
|
||||
usage: PiMessagesUsage;
|
||||
responseId?: string;
|
||||
providerThinkingLevel?: string;
|
||||
rewrite?: PiMessagesRewriteImpact;
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
reason: Extract<PiMessagesStopReason, "aborted" | "error">;
|
||||
usage: PiMessagesUsage;
|
||||
errorMessage?: string;
|
||||
responseId?: string;
|
||||
providerThinkingLevel?: string;
|
||||
rewrite?: PiMessagesRewriteImpact;
|
||||
};
|
||||
|
||||
type PiMessagesErrorBody = {
|
||||
error?: {
|
||||
message?: unknown;
|
||||
code?: unknown;
|
||||
details?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export class PiMessagesResponseError extends Error {
|
||||
code?: string;
|
||||
readonly diagnosticDetails: Record<string, unknown>;
|
||||
|
||||
constructor(message: string, code: string | undefined, diagnosticDetails: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = "PiMessagesResponseError";
|
||||
this.code = code;
|
||||
this.diagnosticDetails = diagnosticDetails;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePiMessagesErrorBody(body: string): PiMessagesErrorBody | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as PiMessagesErrorBody | null;
|
||||
const error = parsed?.error;
|
||||
return parsed && typeof error === "object" && error !== null && !Array.isArray(error) ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function truncateDiagnosticString(value: string): string {
|
||||
const maxLength = 8192;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
||||
}
|
||||
|
||||
function formatPiMessagesResponseError(
|
||||
response: Response,
|
||||
body: string,
|
||||
errorBody: PiMessagesErrorBody | undefined,
|
||||
): string {
|
||||
const message = typeof errorBody?.error?.message === "string" ? errorBody.error.message : undefined;
|
||||
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
|
||||
const suffix = message ?? body;
|
||||
const codeSuffix = code ? ` (${code})` : "";
|
||||
return `${response.status} ${response.statusText}: ${suffix}${codeSuffix}`;
|
||||
}
|
||||
|
||||
function createPiMessagesResponseError(
|
||||
model: Model<"pi-messages">,
|
||||
url: URL,
|
||||
response: Response,
|
||||
body: string,
|
||||
): PiMessagesResponseError {
|
||||
const errorBody = parsePiMessagesErrorBody(body);
|
||||
const code = typeof errorBody?.error?.code === "string" ? errorBody.error.code : undefined;
|
||||
return new PiMessagesResponseError(formatPiMessagesResponseError(response, body, errorBody), code, {
|
||||
version: 1,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
url: url.toString(),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorBody?.error,
|
||||
body: errorBody ? undefined : truncateDiagnosticString(body),
|
||||
timestampMs: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function createEmptyUsage(): PiMessagesUsage {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function appendRewriteDiagnostic(message: AssistantMessage, rewrite: PiMessagesRewriteImpact | undefined): void {
|
||||
if (!rewrite) {
|
||||
return;
|
||||
}
|
||||
appendAssistantMessageDiagnostic(message, {
|
||||
type: "pi_messages_rewrite",
|
||||
timestamp: Date.now(),
|
||||
details: { ...rewrite },
|
||||
});
|
||||
}
|
||||
|
||||
function createEventConverter(model: Model<"pi-messages">) {
|
||||
const partial: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createEmptyUsage(),
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const toolJson = new Map<number, string>();
|
||||
|
||||
return (event: PiMessagesEvent): AssistantMessageEvent => {
|
||||
switch (event.type) {
|
||||
case "done":
|
||||
Object.assign(partial, {
|
||||
stopReason: event.reason,
|
||||
usage: event.usage,
|
||||
responseId: event.responseId,
|
||||
});
|
||||
if (event.providerThinkingLevel !== undefined) {
|
||||
partial.providerThinkingLevel = event.providerThinkingLevel;
|
||||
}
|
||||
appendRewriteDiagnostic(partial, event.rewrite);
|
||||
return { type: "done", reason: event.reason, message: partial };
|
||||
case "error":
|
||||
Object.assign(partial, {
|
||||
stopReason: event.reason,
|
||||
usage: event.usage,
|
||||
errorMessage: event.errorMessage,
|
||||
responseId: event.responseId,
|
||||
});
|
||||
if (event.providerThinkingLevel !== undefined) {
|
||||
partial.providerThinkingLevel = event.providerThinkingLevel;
|
||||
}
|
||||
appendRewriteDiagnostic(partial, event.rewrite);
|
||||
return { type: "error", reason: event.reason, error: partial };
|
||||
case "start":
|
||||
break;
|
||||
case "text_start":
|
||||
partial.content[event.contentIndex] = { type: "text", text: "" };
|
||||
break;
|
||||
case "text_delta":
|
||||
(partial.content[event.contentIndex] as { text: string }).text += event.delta;
|
||||
break;
|
||||
case "text_end":
|
||||
Object.assign(partial.content[event.contentIndex]!, {
|
||||
text: event.content,
|
||||
textSignature: event.contentSignature,
|
||||
});
|
||||
break;
|
||||
case "thinking_start":
|
||||
partial.content[event.contentIndex] = { type: "thinking", thinking: "" };
|
||||
break;
|
||||
case "thinking_delta":
|
||||
(partial.content[event.contentIndex] as { thinking: string }).thinking += event.delta;
|
||||
break;
|
||||
case "thinking_end":
|
||||
Object.assign(partial.content[event.contentIndex]!, {
|
||||
thinking: event.content,
|
||||
thinkingSignature: event.contentSignature,
|
||||
redacted: event.redacted,
|
||||
});
|
||||
break;
|
||||
case "toolcall_start":
|
||||
partial.content[event.contentIndex] = {
|
||||
type: "toolCall",
|
||||
id: event.id,
|
||||
name: event.toolName,
|
||||
arguments: {},
|
||||
};
|
||||
toolJson.set(event.contentIndex, "");
|
||||
break;
|
||||
case "toolcall_delta": {
|
||||
const json = `${toolJson.get(event.contentIndex) ?? ""}${event.delta}`;
|
||||
toolJson.set(event.contentIndex, json);
|
||||
(partial.content[event.contentIndex] as ToolCall).arguments =
|
||||
parseStreamingJson<ToolCall["arguments"]>(json);
|
||||
break;
|
||||
}
|
||||
case "toolcall_end":
|
||||
Object.assign(partial.content[event.contentIndex]!, event.toolCall);
|
||||
toolJson.delete(event.contentIndex);
|
||||
return {
|
||||
type: "toolcall_end",
|
||||
contentIndex: event.contentIndex,
|
||||
toolCall: partial.content[event.contentIndex] as ToolCall,
|
||||
partial,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...event, partial } as AssistantMessageEvent;
|
||||
};
|
||||
}
|
||||
|
||||
async function* readPiMessagesEvents(stream: ReadableStream<Uint8Array>): AsyncGenerator<PiMessagesEvent> {
|
||||
const decoder = new TextDecoder();
|
||||
const reader = stream.getReader();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
|
||||
let split = buffer.indexOf("\n\n");
|
||||
while (split !== -1) {
|
||||
const event = parsePiMessagesEvent(buffer.slice(0, split));
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
buffer = buffer.slice(split + 2);
|
||||
split = buffer.indexOf("\n\n");
|
||||
}
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const event = parsePiMessagesEvent(buffer);
|
||||
if (event) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
function parsePiMessagesEvent(raw: string): PiMessagesEvent | undefined {
|
||||
const data = raw
|
||||
.split("\n")
|
||||
.find((line) => line.startsWith("data:"))
|
||||
?.slice(5)
|
||||
.trim();
|
||||
|
||||
return data && data !== "[DONE]" ? (JSON.parse(data) as PiMessagesEvent) : undefined;
|
||||
}
|
||||
|
||||
function createErrorEvent(model: Model<"pi-messages">, error: unknown, aborted: boolean): AssistantMessageEvent {
|
||||
const reason = aborted ? "aborted" : "error";
|
||||
const assistantMessage: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createEmptyUsage(),
|
||||
stopReason: reason,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
if (!aborted && error instanceof PiMessagesResponseError) {
|
||||
appendAssistantMessageDiagnostic(
|
||||
assistantMessage,
|
||||
createAssistantMessageDiagnostic("pi_messages_response_failure", error, error.diagnosticDetails),
|
||||
);
|
||||
}
|
||||
|
||||
return { type: "error", reason, error: assistantMessage };
|
||||
}
|
||||
|
||||
function resolveCacheRetention(cacheRetention?: CacheRetention, env?: ProviderEnv): CacheRetention | undefined {
|
||||
if (cacheRetention) {
|
||||
return cacheRetention;
|
||||
}
|
||||
// Backend defaults apply when unset; only the legacy env opt-in is mapped.
|
||||
return getProviderEnvValue("PI_CACHE_RETENTION", env) === "long" ? "long" : undefined;
|
||||
}
|
||||
|
||||
export const stream: StreamFunction<"pi-messages", PiMessagesOptions> = (
|
||||
model: Model<"pi-messages">,
|
||||
context: Context,
|
||||
options?: PiMessagesOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const eventStream = new AssistantMessageEventStream();
|
||||
const convertEvent = createEventConverter(model);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const apiKey = options?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error(`No API key provided for provider "${model.provider}"`);
|
||||
}
|
||||
|
||||
const url = new URL(`${model.baseUrl.replace(/\/+$/u, "")}/messages`);
|
||||
if (options?.debug) {
|
||||
url.searchParams.set("debug", "1");
|
||||
}
|
||||
|
||||
let payload: unknown = {
|
||||
model: model.id,
|
||||
context,
|
||||
options: {
|
||||
temperature: options?.temperature,
|
||||
maxTokens: options?.maxTokens,
|
||||
reasoning: options?.reasoning,
|
||||
cacheRetention: resolveCacheRetention(options?.cacheRetention, options?.env),
|
||||
sessionId: options?.sessionId,
|
||||
toolChoice: options?.toolChoice,
|
||||
},
|
||||
};
|
||||
const nextPayload = await options?.onPayload?.(payload, model);
|
||||
if (nextPayload !== undefined) {
|
||||
payload = nextPayload;
|
||||
}
|
||||
|
||||
const response = await (options?.fetch ?? globalThis.fetch)(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
accept: "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
...providerHeadersToRecord(options?.headers),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
await options?.onResponse?.({ status: response.status, headers: headersToRecord(response.headers) }, model);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw createPiMessagesResponseError(model, url, response, body);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error(`${model.provider} response has no body`);
|
||||
}
|
||||
|
||||
for await (const piEvent of readPiMessagesEvents(response.body)) {
|
||||
const event = convertEvent(piEvent);
|
||||
eventStream.push(event);
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`${model.provider} stream ended without a terminal event`);
|
||||
} catch (error) {
|
||||
eventStream.push(createErrorEvent(model, error, options?.signal?.aborted ?? false));
|
||||
}
|
||||
})();
|
||||
|
||||
return eventStream;
|
||||
};
|
||||
|
||||
export const streamSimple: StreamFunction<"pi-messages", SimpleStreamOptions> = (
|
||||
model: Model<"pi-messages">,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream => {
|
||||
const extra = options as PiMessagesOptions | undefined;
|
||||
return stream(model, context, {
|
||||
...options,
|
||||
reasoning: options?.reasoning,
|
||||
toolChoice: options?.toolChoice,
|
||||
debug: extra?.debug,
|
||||
});
|
||||
};
|
||||
95
packages/ai/src/api/simple-options.ts
Normal file
95
packages/ai/src/api/simple-options.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type {
|
||||
Api,
|
||||
Context,
|
||||
Model,
|
||||
SimpleStreamOptions,
|
||||
StreamOptions,
|
||||
ThinkingBudgets,
|
||||
ThinkingLevel,
|
||||
} from "../types.ts";
|
||||
import { estimateContextTokens } from "../utils/estimate.ts";
|
||||
|
||||
const CONTEXT_SAFETY_TOKENS = 4096;
|
||||
const MIN_MAX_TOKENS = 1;
|
||||
|
||||
export function clampMaxTokensToContext(model: Model<Api>, context: Context, maxTokens: number): number {
|
||||
if (model.contextWindow <= 0) return Math.max(MIN_MAX_TOKENS, maxTokens);
|
||||
const available = model.contextWindow - estimateContextTokens(context).tokens - CONTEXT_SAFETY_TOKENS;
|
||||
return Math.min(maxTokens, Math.max(MIN_MAX_TOKENS, available));
|
||||
}
|
||||
|
||||
export function buildBaseOptions(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
apiKey?: string,
|
||||
): StreamOptions {
|
||||
const samplingParams =
|
||||
model.samplingParams || options?.samplingParams
|
||||
? { ...model.samplingParams, ...options?.samplingParams }
|
||||
: undefined;
|
||||
return {
|
||||
temperature: options?.temperature,
|
||||
samplingParams,
|
||||
maxTokens: clampMaxTokensToContext(model, context, options?.maxTokens ?? model.maxTokens),
|
||||
signal: options?.signal,
|
||||
telemetryContext: options?.telemetryContext,
|
||||
apiKey: apiKey || options?.apiKey,
|
||||
fetch: options?.fetch,
|
||||
transport: options?.transport,
|
||||
cacheRetention: options?.cacheRetention,
|
||||
sessionId: options?.sessionId,
|
||||
headers: options?.headers,
|
||||
onPayload: options?.onPayload,
|
||||
onResponse: options?.onResponse,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
websocketConnectTimeoutMs: options?.websocketConnectTimeoutMs,
|
||||
maxRetries: options?.maxRetries,
|
||||
maxRetryDelayMs: options?.maxRetryDelayMs,
|
||||
metadata: options?.metadata,
|
||||
env: options?.env,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tokens always left for the answer when a thinking budget shares the response ceiling. */
|
||||
export const MIN_ANSWER_TOKENS = 1024;
|
||||
|
||||
export const DEFAULT_THINKING_BUDGETS: ThinkingBudgets = {
|
||||
minimal: 1024,
|
||||
low: 2048,
|
||||
medium: 8192,
|
||||
high: 16384,
|
||||
};
|
||||
|
||||
export function clampReasoning(effort: ThinkingLevel | undefined): Exclude<ThinkingLevel, "xhigh" | "max"> | undefined {
|
||||
return effort === "xhigh" || effort === "max" ? "high" : effort;
|
||||
}
|
||||
|
||||
export function thinkingBudgetForLevel(reasoningLevel: ThinkingLevel, customBudgets?: ThinkingBudgets): number {
|
||||
const budgets = { ...DEFAULT_THINKING_BUDGETS, ...customBudgets };
|
||||
const level = clampReasoning(reasoningLevel)!;
|
||||
return budgets[level]!;
|
||||
}
|
||||
|
||||
/** Cap a thinking budget so at least MIN_ANSWER_TOKENS remain under a shared response ceiling. */
|
||||
export function clampThinkingBudgetToAnswerRoom(thinkingBudget: number, ceiling: number): number {
|
||||
return Math.min(thinkingBudget, Math.max(0, ceiling - MIN_ANSWER_TOKENS));
|
||||
}
|
||||
|
||||
export function adjustMaxTokensForThinking(
|
||||
// Undefined means no explicit caller cap. Use the model cap and fit thinking inside it.
|
||||
baseMaxTokens: number | undefined,
|
||||
modelMaxTokens: number,
|
||||
reasoningLevel: ThinkingLevel,
|
||||
customBudgets?: ThinkingBudgets,
|
||||
): { maxTokens: number; thinkingBudget: number } {
|
||||
let thinkingBudget = thinkingBudgetForLevel(reasoningLevel, customBudgets);
|
||||
const maxTokens =
|
||||
baseMaxTokens === undefined ? modelMaxTokens : Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens);
|
||||
|
||||
if (maxTokens <= thinkingBudget) {
|
||||
thinkingBudget = clampThinkingBudgetToAnswerRoom(thinkingBudget, maxTokens);
|
||||
}
|
||||
|
||||
return { maxTokens, thinkingBudget };
|
||||
}
|
||||
223
packages/ai/src/api/transform-messages.ts
Normal file
223
packages/ai/src/api/transform-messages.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import type {
|
||||
Api,
|
||||
AssistantMessage,
|
||||
ImageContent,
|
||||
Message,
|
||||
Model,
|
||||
TextContent,
|
||||
ToolCall,
|
||||
ToolResultMessage,
|
||||
} from "../types.ts";
|
||||
|
||||
const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
|
||||
const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
|
||||
|
||||
function replaceImagesWithPlaceholder(content: (TextContent | ImageContent)[], placeholder: string): TextContent[] {
|
||||
const result: TextContent[] = [];
|
||||
let previousWasPlaceholder = false;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === "image") {
|
||||
if (!previousWasPlaceholder) {
|
||||
result.push({ type: "text", text: placeholder });
|
||||
}
|
||||
previousWasPlaceholder = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(block);
|
||||
previousWasPlaceholder = block.text === placeholder;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function downgradeUnsupportedImages<TApi extends Api>(messages: Message[], model: Model<TApi>): Message[] {
|
||||
if (model.input.includes("image")) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((msg) => {
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
return {
|
||||
...msg,
|
||||
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_USER_IMAGE_PLACEHOLDER),
|
||||
};
|
||||
}
|
||||
|
||||
if (msg.role === "toolResult") {
|
||||
return {
|
||||
...msg,
|
||||
content: replaceImagesWithPlaceholder(msg.content, NON_VISION_TOOL_IMAGE_PLACEHOLDER),
|
||||
};
|
||||
}
|
||||
|
||||
return msg;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize tool call ID for cross-provider compatibility.
|
||||
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
||||
* Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
|
||||
*/
|
||||
export function transformMessages<TApi extends Api>(
|
||||
messages: Message[],
|
||||
model: Model<TApi>,
|
||||
normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string,
|
||||
): Message[] {
|
||||
// Build a map of original tool call IDs to normalized IDs
|
||||
const toolCallIdMap = new Map<string, string>();
|
||||
// Normalize null/undefined content from untyped callers (custom tools, hand-built
|
||||
// histories, old session files) so downstream code can rely on the type contract.
|
||||
const normalizedMessages = messages.map((msg) => (msg.content == null ? { ...msg, content: [] } : msg));
|
||||
const imageAwareMessages = downgradeUnsupportedImages(normalizedMessages, model);
|
||||
|
||||
// First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization)
|
||||
const transformed = imageAwareMessages.map((msg) => {
|
||||
// User messages pass through unchanged
|
||||
if (msg.role === "user") {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Handle toolResult messages - normalize toolCallId if we have a mapping
|
||||
if (msg.role === "toolResult") {
|
||||
const normalizedId = toolCallIdMap.get(msg.toolCallId);
|
||||
if (normalizedId && normalizedId !== msg.toolCallId) {
|
||||
return { ...msg, toolCallId: normalizedId };
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Assistant messages need transformation check
|
||||
if (msg.role === "assistant") {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
const isSameModel =
|
||||
assistantMsg.provider === model.provider &&
|
||||
assistantMsg.api === model.api &&
|
||||
assistantMsg.model === model.id;
|
||||
|
||||
const transformedContent = assistantMsg.content.flatMap((block) => {
|
||||
if (block.type === "thinking") {
|
||||
// Redacted thinking is opaque encrypted content, only valid for the same model.
|
||||
// Drop it for cross-model to avoid API errors.
|
||||
if (block.redacted) {
|
||||
return isSameModel ? block : [];
|
||||
}
|
||||
// For same model: keep thinking blocks with signatures (needed for replay)
|
||||
// even if the thinking text is empty (OpenAI encrypted reasoning)
|
||||
if (isSameModel && block.thinkingSignature) return block;
|
||||
// Skip empty thinking blocks, convert others to plain text
|
||||
if (!block.thinking || block.thinking.trim() === "") return [];
|
||||
if (isSameModel) return block;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: block.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
if (isSameModel) return block;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: block.text,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.type === "toolCall") {
|
||||
const toolCall = block as ToolCall;
|
||||
let normalizedToolCall: ToolCall = toolCall;
|
||||
|
||||
if (!isSameModel && toolCall.thoughtSignature) {
|
||||
normalizedToolCall = { ...toolCall };
|
||||
delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
|
||||
}
|
||||
|
||||
if (!isSameModel && normalizeToolCallId) {
|
||||
const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
|
||||
if (normalizedId !== toolCall.id) {
|
||||
toolCallIdMap.set(toolCall.id, normalizedId);
|
||||
normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedToolCall;
|
||||
}
|
||||
|
||||
return block;
|
||||
});
|
||||
|
||||
return {
|
||||
...assistantMsg,
|
||||
content: transformedContent,
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
|
||||
// Second pass: insert synthetic empty tool results for orphaned tool calls
|
||||
// This preserves thinking signatures and satisfies API requirements
|
||||
const result: Message[] = [];
|
||||
let pendingToolCalls: ToolCall[] = [];
|
||||
let existingToolResultIds = new Set<string>();
|
||||
const insertSyntheticToolResults = () => {
|
||||
if (pendingToolCalls.length > 0) {
|
||||
for (const tc of pendingToolCalls) {
|
||||
if (!existingToolResultIds.has(tc.id)) {
|
||||
result.push({
|
||||
role: "toolResult",
|
||||
toolCallId: tc.id,
|
||||
toolName: tc.name,
|
||||
content: [{ type: "text", text: "No result provided" }],
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
} as ToolResultMessage);
|
||||
}
|
||||
}
|
||||
pendingToolCalls = [];
|
||||
existingToolResultIds = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < transformed.length; i++) {
|
||||
const msg = transformed[i];
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
// If we have pending orphaned tool calls from a previous assistant, insert synthetic results now
|
||||
insertSyntheticToolResults();
|
||||
|
||||
// Skip errored/aborted assistant messages entirely.
|
||||
// These are incomplete turns that shouldn't be replayed:
|
||||
// - May have partial content (reasoning without message, incomplete tool calls)
|
||||
// - Replaying them can cause API errors (e.g., OpenAI "reasoning without following item")
|
||||
// - The model should retry from the last valid state
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track tool calls from this assistant message
|
||||
const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[];
|
||||
if (toolCalls.length > 0) {
|
||||
pendingToolCalls = toolCalls;
|
||||
existingToolResultIds = new Set();
|
||||
}
|
||||
|
||||
result.push(msg);
|
||||
} else if (msg.role === "toolResult") {
|
||||
existingToolResultIds.add(msg.toolCallId);
|
||||
result.push(msg);
|
||||
} else if (msg.role === "user") {
|
||||
// User message interrupts tool flow - insert synthetic results for orphaned calls
|
||||
insertSyntheticToolResults();
|
||||
result.push(msg);
|
||||
} else {
|
||||
result.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// If the conversation ends with unresolved tool calls, synthesize results now.
|
||||
insertSyntheticToolResults();
|
||||
|
||||
return result;
|
||||
}
|
||||
45
packages/ai/src/auth/context.ts
Normal file
45
packages/ai/src/auth/context.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { AuthContext } from "./types.ts";
|
||||
|
||||
interface NodeFsModule {
|
||||
access(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface NodeOsModule {
|
||||
homedir(): string;
|
||||
}
|
||||
|
||||
// Variable specifier so browser bundlers do not try to resolve node builtins.
|
||||
const importNodeModule = (specifier: string): Promise<unknown> => import(specifier);
|
||||
|
||||
function getProcessEnv(): Record<string, string | undefined> | undefined {
|
||||
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
|
||||
return proc?.env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auth context: env vars from `process.env` (undefined in browsers),
|
||||
* file existence via node:fs (always false in browsers).
|
||||
*/
|
||||
export function defaultProviderAuthContext(): AuthContext {
|
||||
return {
|
||||
async env(name: string): Promise<string | undefined> {
|
||||
const value = getProcessEnv()?.[name];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
},
|
||||
|
||||
async fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const fs = (await importNodeModule("node:fs/promises")) as NodeFsModule;
|
||||
let resolved = path;
|
||||
if (resolved.startsWith("~")) {
|
||||
const os = (await importNodeModule("node:os")) as NodeOsModule;
|
||||
resolved = os.homedir() + resolved.slice(1);
|
||||
}
|
||||
await fs.access(resolved);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
67
packages/ai/src/auth/credential-store.ts
Normal file
67
packages/ai/src/auth/credential-store.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
|
||||
import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Default in-memory credential store. Apps inject persistent stores.
|
||||
* Keyed by `Provider.id`, one credential per provider; see `CredentialStore`.
|
||||
* Writes are serialized per provider through a promise chain.
|
||||
*/
|
||||
export class InMemoryCredentialStore implements CredentialStore {
|
||||
private credentials = new Map<string, Credential>();
|
||||
private chains = new Map<string, Promise<unknown>>();
|
||||
|
||||
/** Serialize tasks per provider id without releasing the chain before active work settles. */
|
||||
private enqueue<T>(providerId: string, task: () => Promise<T>, options?: AuthOperationOptions): Promise<T> {
|
||||
const signal = operationSignal(options?.signal);
|
||||
const previous = this.chains.get(providerId) ?? Promise.resolve();
|
||||
const queued = (async () => {
|
||||
await previous.catch(() => {});
|
||||
signal.throwIfAborted();
|
||||
return task();
|
||||
})();
|
||||
const tail = queued.catch(() => {});
|
||||
this.chains.set(providerId, tail);
|
||||
void tail.then(() => {
|
||||
if (this.chains.get(providerId) === tail) this.chains.delete(providerId);
|
||||
});
|
||||
return raceWithAbortSignal(queued, signal);
|
||||
}
|
||||
|
||||
async read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined> {
|
||||
options?.signal?.throwIfAborted();
|
||||
return this.credentials.get(providerId);
|
||||
}
|
||||
|
||||
async list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]> {
|
||||
options?.signal?.throwIfAborted();
|
||||
return [...this.credentials].map(([providerId, credential]) => ({ providerId, type: credential.type }));
|
||||
}
|
||||
|
||||
modify(
|
||||
providerId: string,
|
||||
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
|
||||
options?: AuthOperationOptions,
|
||||
): Promise<Credential | undefined> {
|
||||
return this.enqueue(
|
||||
providerId,
|
||||
async () => {
|
||||
const current = this.credentials.get(providerId);
|
||||
const next = await fn(current);
|
||||
options?.signal?.throwIfAborted();
|
||||
if (next !== undefined) this.credentials.set(providerId, next);
|
||||
return next ?? current;
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
delete(providerId: string, options?: AuthOperationOptions): Promise<void> {
|
||||
return this.enqueue(
|
||||
providerId,
|
||||
async () => {
|
||||
this.credentials.delete(providerId);
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
59
packages/ai/src/auth/helpers.ts
Normal file
59
packages/ai/src/auth/helpers.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ApiKeyAuth, OAuthAuth } from "./types.ts";
|
||||
|
||||
/**
|
||||
* Standard api-key auth: a stored credential key wins, otherwise the first
|
||||
* set env var resolves. Includes a `login` that prompts for the key.
|
||||
* Providers with non-standard resolution (provider env, ambient files, IAM)
|
||||
* write their own `ApiKeyAuth`.
|
||||
*/
|
||||
export function envApiKeyAuth(name: string, envVars: readonly string[]): ApiKeyAuth {
|
||||
return {
|
||||
name,
|
||||
login: async (interaction) => {
|
||||
interaction.signal.throwIfAborted();
|
||||
const key = await interaction.prompt({ type: "secret", message: `Enter ${name}` });
|
||||
interaction.signal.throwIfAborted();
|
||||
return { type: "api_key", key };
|
||||
},
|
||||
resolve: async ({ ctx, credential, signal }) => {
|
||||
signal.throwIfAborted();
|
||||
if (credential?.key) {
|
||||
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
|
||||
}
|
||||
for (const envVar of envVars) {
|
||||
const value = await ctx.env(envVar);
|
||||
signal.throwIfAborted();
|
||||
if (value) return { auth: { apiKey: value }, source: envVar };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a dynamically imported `OAuthAuth` so provider definitions can
|
||||
* advertise OAuth without importing the implementation. The flow loads on
|
||||
* first `login`/`refresh`/`toAuth` call; callers keep Node-only flow code out
|
||||
* of bundles by loading through a bundler-opaque dynamic import (variable
|
||||
* specifier, see the bedrock lazy wrapper).
|
||||
*/
|
||||
export function lazyOAuth(input: {
|
||||
name: string;
|
||||
isSubscription?: boolean;
|
||||
loginLabel?: string;
|
||||
load: () => Promise<OAuthAuth>;
|
||||
}): OAuthAuth {
|
||||
let promise: Promise<OAuthAuth> | undefined;
|
||||
const loaded = () => {
|
||||
promise ??= input.load();
|
||||
return promise;
|
||||
};
|
||||
return {
|
||||
name: input.name,
|
||||
isSubscription: input.isSubscription,
|
||||
loginLabel: input.loginLabel,
|
||||
login: async (interaction) => (await loaded()).login(interaction),
|
||||
refresh: async (credential, signal) => (await loaded()).refresh(credential, signal),
|
||||
toAuth: async (credential) => (await loaded()).toAuth(credential),
|
||||
};
|
||||
}
|
||||
364
packages/ai/src/auth/oauth/anthropic.ts
Normal file
364
packages/ai/src/auth/oauth/anthropic.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Anthropic OAuth flow (Claude Pro/Max)
|
||||
*
|
||||
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
import type { Server } from "node:http";
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
type CallbackServerInfo = {
|
||||
server: Server;
|
||||
redirectUri: string;
|
||||
cancelWait: () => void;
|
||||
waitForCode: () => Promise<{ code: string; state: string } | null>;
|
||||
};
|
||||
|
||||
type NodeApis = {
|
||||
createServer: typeof import("node:http").createServer;
|
||||
};
|
||||
|
||||
let nodeApis: NodeApis | null = null;
|
||||
let nodeApisPromise: Promise<NodeApis> | null = null;
|
||||
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
const CALLBACK_PORT = 53692;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const SCOPES =
|
||||
"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
|
||||
async function getNodeApis(): Promise<NodeApis> {
|
||||
if (nodeApis) return nodeApis;
|
||||
if (!nodeApisPromise) {
|
||||
if (typeof process === "undefined" || (!process.versions?.node && !process.versions?.bun)) {
|
||||
throw new Error("Anthropic OAuth is only available in Node.js environments");
|
||||
}
|
||||
nodeApisPromise = import("node:http").then((httpModule) => ({
|
||||
createServer: httpModule.createServer,
|
||||
}));
|
||||
}
|
||||
nodeApis = await nodeApisPromise;
|
||||
return nodeApis;
|
||||
}
|
||||
|
||||
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
||||
const value = input.trim();
|
||||
if (!value) return {};
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return {
|
||||
code: url.searchParams.get("code") ?? undefined,
|
||||
state: url.searchParams.get("state") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
|
||||
if (value.includes("#")) {
|
||||
const [code, state] = value.split("#", 2);
|
||||
return { code, state };
|
||||
}
|
||||
|
||||
if (value.includes("code=")) {
|
||||
const params = new URLSearchParams(value);
|
||||
return {
|
||||
code: params.get("code") ?? undefined,
|
||||
state: params.get("state") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { code: value };
|
||||
}
|
||||
|
||||
function formatErrorDetails(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const details: string[] = [`${error.name}: ${error.message}`];
|
||||
const errorWithCode = error as Error & { code?: string; errno?: number | string; cause?: unknown };
|
||||
if (errorWithCode.code) details.push(`code=${errorWithCode.code}`);
|
||||
if (typeof errorWithCode.errno !== "undefined") details.push(`errno=${String(errorWithCode.errno)}`);
|
||||
if (typeof error.cause !== "undefined") {
|
||||
details.push(`cause=${formatErrorDetails(error.cause)}`);
|
||||
}
|
||||
if (error.stack) {
|
||||
details.push(`stack=${error.stack}`);
|
||||
}
|
||||
return details.join("; ");
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
async function startCallbackServer(expectedState: string): Promise<CallbackServerInfo> {
|
||||
const { createServer } = await getNodeApis();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settleWait: ((value: { code: string; state: string } | null) => void) | undefined;
|
||||
const waitForCodePromise = new Promise<{ code: string; state: string } | null>((resolveWait) => {
|
||||
let settled = false;
|
||||
settleWait = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveWait(value);
|
||||
};
|
||||
});
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || "", "http://localhost");
|
||||
if (url.pathname !== CALLBACK_PATH) {
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Anthropic authentication did not complete.", `Error: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("Missing code or state parameter."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthErrorHtml("State mismatch."));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(oauthSuccessHtml("Anthropic authentication completed. You can close this window."));
|
||||
settleWait?.({ code, state });
|
||||
} catch {
|
||||
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("Internal error");
|
||||
}
|
||||
});
|
||||
|
||||
server.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
||||
resolve({
|
||||
server,
|
||||
redirectUri: REDIRECT_URI,
|
||||
cancelWait: () => {
|
||||
settleWait?.(null);
|
||||
},
|
||||
waitForCode: () => waitForCodePromise,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: Record<string, string | number>, signal: AbortSignal): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP request failed. status=${response.status}; url=${url}; body=${responseBody}`);
|
||||
}
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
state: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
let responseBody: string;
|
||||
try {
|
||||
responseBody = await postJson(
|
||||
TOKEN_URL,
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
state,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: verifier,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Token exchange request failed. url=${TOKEN_URL}; redirect_uri=${redirectUri}; response_type=authorization_code; details=${formatErrorDetails(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let tokenData: { access_token: string; refresh_token: string; expires_in: number };
|
||||
try {
|
||||
tokenData = JSON.parse(responseBody) as { access_token: string; refresh_token: string; expires_in: number };
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Token exchange returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: tokenData.refresh_token,
|
||||
access: tokenData.access_token,
|
||||
expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function loginAnthropic(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const server = await startCallbackServer(verifier);
|
||||
const manualAbort = new AbortController();
|
||||
const onAbort = () => server.cancelWait();
|
||||
interaction.signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (interaction.signal.aborted) onAbort();
|
||||
let code: string | undefined;
|
||||
let state: string | undefined;
|
||||
let manualInput: string | undefined;
|
||||
let manualError: Error | undefined;
|
||||
|
||||
try {
|
||||
const authParams = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: CLIENT_ID,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPES,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
state: verifier,
|
||||
});
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: `${AUTHORIZE_URL}?${authParams.toString()}`,
|
||||
instructions:
|
||||
"Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.",
|
||||
});
|
||||
|
||||
const manualPromise = interaction
|
||||
.prompt({
|
||||
type: "manual_code",
|
||||
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
|
||||
placeholder: REDIRECT_URI,
|
||||
signal: manualAbort.signal,
|
||||
})
|
||||
.then((input) => {
|
||||
manualInput = input;
|
||||
server.cancelWait();
|
||||
})
|
||||
.catch((error) => {
|
||||
manualError = error instanceof Error ? error : new Error(String(error));
|
||||
server.cancelWait();
|
||||
});
|
||||
|
||||
const result = await server.waitForCode();
|
||||
if (manualError) throw manualError;
|
||||
if (result?.code) {
|
||||
code = result.code;
|
||||
state = result.state;
|
||||
} else if (manualInput) {
|
||||
const parsed = parseAuthorizationInput(manualInput);
|
||||
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
||||
code = parsed.code;
|
||||
state = parsed.state ?? verifier;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
if (manualError) throw manualError;
|
||||
if (manualInput) {
|
||||
const parsed = parseAuthorizationInput(manualInput);
|
||||
if (parsed.state && parsed.state !== verifier) throw new Error("OAuth state mismatch");
|
||||
code = parsed.code;
|
||||
state = parsed.state ?? verifier;
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) throw new Error("Missing authorization code");
|
||||
if (!state) throw new Error("Missing OAuth state");
|
||||
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
|
||||
return exchangeAuthorizationCode(code, state, verifier, REDIRECT_URI, interaction.signal);
|
||||
} finally {
|
||||
interaction.signal.removeEventListener("abort", onAbort);
|
||||
manualAbort.abort();
|
||||
server.server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Anthropic OAuth token
|
||||
*/
|
||||
async function refreshAnthropicToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
let responseBody: string;
|
||||
try {
|
||||
responseBody = await postJson(
|
||||
TOKEN_URL,
|
||||
{
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Anthropic token refresh request failed. url=${TOKEN_URL}; details=${formatErrorDetails(error)}`);
|
||||
}
|
||||
|
||||
let data: { access_token: string; refresh_token: string; expires_in: number; scope?: string };
|
||||
try {
|
||||
data = JSON.parse(responseBody) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Anthropic token refresh returned invalid JSON. url=${TOKEN_URL}; body=${responseBody}; details=${formatErrorDetails(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: data.refresh_token,
|
||||
access: data.access_token,
|
||||
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
export const anthropicOAuth: OAuthAuth = {
|
||||
name: "Anthropic (Claude Pro/Max)",
|
||||
isSubscription: true,
|
||||
login: loginAnthropic,
|
||||
refresh: (credential, signal) => refreshAnthropicToken(credential.refresh, signal),
|
||||
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
98
packages/ai/src/auth/oauth/device-code.ts
Normal file
98
packages/ai/src/auth/oauth/device-code.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
const CANCEL_MESSAGE = "Login cancelled";
|
||||
const TIMEOUT_MESSAGE = "Device flow timed out";
|
||||
const SLOW_DOWN_TIMEOUT_MESSAGE =
|
||||
"Device flow timed out after one or more slow_down responses. This is often caused by clock drift in WSL or VM environments. Please sync or restart the VM clock and try again.";
|
||||
const MINIMUM_INTERVAL_MS = 1000;
|
||||
// RFC 8628 section 3.2: if the authorization server omits `interval`, the client must use 5 seconds.
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||
// RFC 8628 section 3.5: `slow_down` means the polling interval must increase by 5 seconds.
|
||||
const SLOW_DOWN_INTERVAL_INCREMENT_MS = 5000;
|
||||
|
||||
type OAuthDeviceCodeIncompletePollResult =
|
||||
| { status: "pending" }
|
||||
| { status: "slow_down"; intervalSeconds?: number }
|
||||
| { status: "failed"; message: string };
|
||||
|
||||
export type OAuthDeviceCodePollResult<T> = OAuthDeviceCodeIncompletePollResult | { status: "complete"; value: T };
|
||||
|
||||
export type OAuthDeviceCodePollOptions<T> = {
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
waitBeforeFirstPoll?: boolean;
|
||||
poll: () => Promise<OAuthDeviceCodePollResult<T>>;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export function abortableSleep(ms: number, signal: AbortSignal, cancelMessage: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(new Error(cancelMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(cancelMessage));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function pollOAuthDeviceCodeFlow<T>(options: OAuthDeviceCodePollOptions<T>): Promise<T> {
|
||||
const deadline =
|
||||
typeof options.expiresInSeconds === "number"
|
||||
? Date.now() + options.expiresInSeconds * 1000
|
||||
: Number.POSITIVE_INFINITY;
|
||||
let intervalMs = Math.max(
|
||||
MINIMUM_INTERVAL_MS,
|
||||
Math.floor((options.intervalSeconds ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000),
|
||||
);
|
||||
|
||||
let slowDownResponses = 0;
|
||||
if (options.waitBeforeFirstPoll) {
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs > 0) {
|
||||
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (options.signal.aborted) {
|
||||
throw new Error(CANCEL_MESSAGE);
|
||||
}
|
||||
|
||||
const result = await options.poll();
|
||||
if (result.status === "complete") {
|
||||
return result.value;
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
if (result.status === "slow_down") {
|
||||
slowDownResponses += 1;
|
||||
// Use the server-provided interval when given (GitHub reports the new required minimum
|
||||
// in `interval`); trusting only a client-tracked value risks polling early forever under
|
||||
// WSL/VM clock drift. Otherwise apply RFC 8628 section 3.5: increase by 5 seconds.
|
||||
intervalMs =
|
||||
typeof result.intervalSeconds === "number" &&
|
||||
Number.isFinite(result.intervalSeconds) &&
|
||||
result.intervalSeconds > 0
|
||||
? Math.max(MINIMUM_INTERVAL_MS, Math.floor(result.intervalSeconds * 1000))
|
||||
: Math.max(MINIMUM_INTERVAL_MS, intervalMs + SLOW_DOWN_INTERVAL_INCREMENT_MS);
|
||||
}
|
||||
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
await abortableSleep(Math.min(intervalMs, remainingMs), options.signal, CANCEL_MESSAGE);
|
||||
}
|
||||
|
||||
throw new Error(slowDownResponses > 0 ? SLOW_DOWN_TIMEOUT_MESSAGE : TIMEOUT_MESSAGE);
|
||||
}
|
||||
507
packages/ai/src/auth/oauth/github-copilot.ts
Normal file
507
packages/ai/src/auth/oauth/github-copilot.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* GitHub Copilot OAuth flow
|
||||
*/
|
||||
|
||||
import { GITHUB_COPILOT_MODELS } from "../../providers/github-copilot.models.ts";
|
||||
import { sleep } from "../../utils/sleep.ts";
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
|
||||
const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");
|
||||
|
||||
const COPILOT_HEADERS = {
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
"Editor-Version": "vscode/1.107.0",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
} as const;
|
||||
const COPILOT_API_VERSION = "2026-06-01";
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
interval?: number;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
type DeviceTokenSuccessResponse = {
|
||||
access_token: string;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
type DeviceTokenErrorResponse = {
|
||||
error: string;
|
||||
error_description?: string;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
function normalizeDomain(input: string): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
const url = trimmed.includes("://") ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getUrls(domain: string): {
|
||||
deviceCodeUrl: string;
|
||||
accessTokenUrl: string;
|
||||
copilotTokenUrl: string;
|
||||
} {
|
||||
return {
|
||||
deviceCodeUrl: `https://${domain}/login/device/code`,
|
||||
accessTokenUrl: `https://${domain}/login/oauth/access_token`,
|
||||
copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the proxy-ep from a Copilot token and convert to API base URL.
|
||||
* Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...
|
||||
* Returns API URL like https://api.individual.githubcopilot.com
|
||||
*/
|
||||
function getBaseUrlFromToken(token: string): string | null {
|
||||
const match = token.match(/proxy-ep=([^;]+)/);
|
||||
if (!match) return null;
|
||||
const proxyHost = match[1];
|
||||
// Convert proxy.xxx to api.xxx
|
||||
const apiHost = proxyHost.replace(/^proxy\./, "api.");
|
||||
return `https://${apiHost}`;
|
||||
}
|
||||
|
||||
function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string {
|
||||
// If we have a token, extract the base URL from proxy-ep
|
||||
if (token) {
|
||||
const urlFromToken = getBaseUrlFromToken(token);
|
||||
if (urlFromToken) return urlFromToken;
|
||||
}
|
||||
// Fallback for enterprise or if token parsing fails
|
||||
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
|
||||
return "https://api.individual.githubcopilot.com";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function parseGitHubCopilotModelCatalog(raw: unknown, allowPolicyFallback: boolean) {
|
||||
const data = asRecord(raw)?.data;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid Copilot models response");
|
||||
}
|
||||
|
||||
const accountModels = data.flatMap((rawItem) => {
|
||||
const item = asRecord(rawItem);
|
||||
const id = item?.id;
|
||||
if (!item || typeof id !== "string") return [];
|
||||
|
||||
const capabilities = asRecord(item.capabilities);
|
||||
const supports = asRecord(capabilities?.supports);
|
||||
if (supports?.tool_calls === false) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id,
|
||||
pickerEnabled: item.model_picker_enabled === true,
|
||||
policyState: asRecord(item.policy)?.state,
|
||||
},
|
||||
];
|
||||
});
|
||||
const pickerModelIds = accountModels
|
||||
.filter((model) => model.pickerEnabled && model.policyState !== "disabled")
|
||||
.map((model) => model.id);
|
||||
const usePolicyFallback = allowPolicyFallback && pickerModelIds.length === 0;
|
||||
const availableModelIds =
|
||||
pickerModelIds.length > 0 || !allowPolicyFallback
|
||||
? pickerModelIds
|
||||
: accountModels.filter((model) => model.policyState === "enabled").map((model) => model.id);
|
||||
const policyModelIds = accountModels
|
||||
.filter(
|
||||
(model) =>
|
||||
model.policyState === "unconfigured" &&
|
||||
Object.hasOwn(GITHUB_COPILOT_MODELS, model.id) &&
|
||||
(model.pickerEnabled || usePolicyFallback),
|
||||
)
|
||||
.map((model) => model.id);
|
||||
return { availableModelIds, policyModelIds };
|
||||
}
|
||||
|
||||
async function fetchWithRateLimitRetry(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
signal: AbortSignal,
|
||||
retryPolicy: { maxRetries: number; maxElapsedMs: number },
|
||||
): Promise<Response> {
|
||||
const retryBudgetSignal =
|
||||
retryPolicy.maxRetries > 0 && retryPolicy.maxElapsedMs > 0
|
||||
? AbortSignal.timeout(retryPolicy.maxElapsedMs)
|
||||
: undefined;
|
||||
const requestSignal = retryBudgetSignal ? AbortSignal.any([signal, retryBudgetSignal]) : signal;
|
||||
const retryDeadline = retryBudgetSignal ? Date.now() + retryPolicy.maxElapsedMs : undefined;
|
||||
for (let retry = 0; ; retry++) {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.any([requestSignal, AbortSignal.timeout(5000)]),
|
||||
});
|
||||
if (response.status !== 429 || retry === retryPolicy.maxRetries) return response;
|
||||
|
||||
const retryAfter = response.headers.get("retry-after");
|
||||
let delayMs = 500 * 2 ** retry;
|
||||
if (retryAfter) {
|
||||
const seconds = Number.parseFloat(retryAfter);
|
||||
delayMs = Number.isNaN(seconds) ? Date.parse(retryAfter) - Date.now() : seconds * 1000;
|
||||
if (!Number.isFinite(delayMs)) return response;
|
||||
}
|
||||
delayMs = Math.max(0, delayMs);
|
||||
if (retryDeadline !== undefined && delayMs >= retryDeadline - Date.now()) return response;
|
||||
await response.body?.cancel();
|
||||
await sleep(delayMs, requestSignal);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGitHubCopilotModels(
|
||||
copilotToken: string,
|
||||
enterpriseDomain: string | undefined,
|
||||
signal: AbortSignal,
|
||||
retryPolicy: { maxRetries: number; maxElapsedMs: number },
|
||||
) {
|
||||
const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain);
|
||||
// Some Individual accounts return false for every picker flag despite explicit enabled policies.
|
||||
// Limit the fallback to that endpoint so other account types keep strict picker semantics.
|
||||
const allowPolicyFallback = baseUrl === "https://api.individual.githubcopilot.com";
|
||||
const response = await fetchWithRateLimitRetry(
|
||||
`${baseUrl}/models`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${copilotToken}`,
|
||||
...COPILOT_HEADERS,
|
||||
"X-GitHub-Api-Version": COPILOT_API_VERSION,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
retryPolicy,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
||||
}
|
||||
return parseGitHubCopilotModelCatalog(await response.json(), allowPolicyFallback);
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${response.status} ${response.statusText}: ${text}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function startDeviceFlow(domain: string, signal: AbortSignal): Promise<DeviceCodeResponse> {
|
||||
const urls = getUrls(domain);
|
||||
const data = await fetchJson(urls.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
scope: "read:user",
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Invalid device code response");
|
||||
}
|
||||
|
||||
const deviceCode = (data as Record<string, unknown>).device_code;
|
||||
const userCode = (data as Record<string, unknown>).user_code;
|
||||
const verificationUri = (data as Record<string, unknown>).verification_uri;
|
||||
const interval = (data as Record<string, unknown>).interval;
|
||||
const expiresIn = (data as Record<string, unknown>).expires_in;
|
||||
|
||||
if (
|
||||
typeof deviceCode !== "string" ||
|
||||
typeof userCode !== "string" ||
|
||||
typeof verificationUri !== "string" ||
|
||||
(interval !== undefined && typeof interval !== "number") ||
|
||||
typeof expiresIn !== "number"
|
||||
) {
|
||||
throw new Error("Invalid device code response fields");
|
||||
}
|
||||
|
||||
// The verification URI is opened in the user's browser and to prevent `open` from
|
||||
// opening an executable or similar, we force it to be a URL.
|
||||
let parsedUri: URL;
|
||||
try {
|
||||
parsedUri = new URL(verificationUri);
|
||||
} catch {
|
||||
throw new Error("Untrusted verification_uri in device code response");
|
||||
}
|
||||
if (parsedUri.protocol !== "https:" && parsedUri.protocol !== "http:") {
|
||||
throw new Error("Untrusted verification_uri in device code response");
|
||||
}
|
||||
|
||||
return {
|
||||
device_code: deviceCode,
|
||||
user_code: userCode,
|
||||
verification_uri: parsedUri.href,
|
||||
interval,
|
||||
expires_in: expiresIn,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollForGitHubAccessToken(
|
||||
domain: string,
|
||||
device: DeviceCodeResponse,
|
||||
signal: AbortSignal,
|
||||
): Promise<string> {
|
||||
const urls = getUrls(domain);
|
||||
return pollOAuthDeviceCodeFlow<string>({
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
waitBeforeFirstPoll: true,
|
||||
signal,
|
||||
poll: async () => {
|
||||
const raw = await fetchJson(urls.accessTokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "GitHubCopilotChat/0.35.0",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: device.device_code,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenSuccessResponse).access_token === "string") {
|
||||
return { status: "complete", value: (raw as DeviceTokenSuccessResponse).access_token };
|
||||
}
|
||||
|
||||
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
||||
const { error, error_description: description, interval } = raw as DeviceTokenErrorResponse;
|
||||
if (error === "authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
if (error === "slow_down") {
|
||||
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
|
||||
}
|
||||
|
||||
const descriptionSuffix = description ? `: ${description}` : "";
|
||||
return { status: "failed", message: `Device flow failed: ${error}${descriptionSuffix}` };
|
||||
}
|
||||
|
||||
return { status: "failed", message: "Invalid device token response" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshGitHubCopilotAccessToken(
|
||||
refreshToken: string,
|
||||
enterpriseDomain: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
const domain = enterpriseDomain || "github.com";
|
||||
const urls = getUrls(domain);
|
||||
|
||||
const raw = await fetchJson(urls.copilotTokenUrl, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${refreshToken}`,
|
||||
...COPILOT_HEADERS,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("Invalid Copilot token response");
|
||||
}
|
||||
|
||||
const token = (raw as Record<string, unknown>).token;
|
||||
const expiresAt = (raw as Record<string, unknown>).expires_at;
|
||||
|
||||
if (typeof token !== "string" || typeof expiresAt !== "number") {
|
||||
throw new Error("Invalid Copilot token response fields");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
refresh: refreshToken,
|
||||
access: token,
|
||||
expires: expiresAt * 1000 - 5 * 60 * 1000,
|
||||
enterpriseUrl: enterpriseDomain,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh GitHub Copilot token
|
||||
*/
|
||||
async function refreshGitHubCopilotToken(
|
||||
refreshToken: string,
|
||||
enterpriseDomain: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain, signal);
|
||||
const { availableModelIds } = await fetchGitHubCopilotModels(credentials.access, enterpriseDomain, signal, {
|
||||
maxRetries: 0,
|
||||
maxElapsedMs: 0,
|
||||
});
|
||||
return {
|
||||
...credentials,
|
||||
availableModelIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a model for the user's GitHub Copilot account.
|
||||
* This is required for some models (like Claude, Grok) before they can be used.
|
||||
*/
|
||||
async function enableGitHubCopilotModel(
|
||||
token: string,
|
||||
modelId: string,
|
||||
enterpriseDomain: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
|
||||
const url = `${baseUrl}/models/${modelId}/policy`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchWithRateLimitRetry(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
...COPILOT_HEADERS,
|
||||
"openai-intent": "chat-policy",
|
||||
"x-interaction-type": "chat-policy",
|
||||
},
|
||||
body: JSON.stringify({ state: "enabled" }),
|
||||
},
|
||||
signal,
|
||||
{ maxRetries: 2, maxElapsedMs: 5000 },
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
return false;
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`);
|
||||
}
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable the requested GitHub Copilot models and return the successful IDs.
|
||||
* Policy updates are best effort; exhausted rate limiting stops the batch.
|
||||
*/
|
||||
async function enableGitHubCopilotModels(
|
||||
token: string,
|
||||
modelIds: readonly string[],
|
||||
enterpriseDomain: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<string[]> {
|
||||
const enabledModelIds: string[] = [];
|
||||
for (const modelId of modelIds) {
|
||||
try {
|
||||
if (await enableGitHubCopilotModel(token, modelId, enterpriseDomain, signal)) {
|
||||
enabledModelIds.push(modelId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return enabledModelIds;
|
||||
}
|
||||
|
||||
async function loginGitHubCopilot(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const input = await interaction.prompt({
|
||||
type: "text",
|
||||
message: "GitHub Enterprise URL/domain (blank for github.com)",
|
||||
placeholder: "company.ghe.com",
|
||||
});
|
||||
if (interaction.signal.aborted) throw new Error("Login cancelled");
|
||||
|
||||
const trimmed = input.trim();
|
||||
const enterpriseDomain = normalizeDomain(input);
|
||||
if (trimmed && !enterpriseDomain) throw new Error("Invalid GitHub Enterprise URL/domain");
|
||||
const domain = enterpriseDomain || "github.com";
|
||||
|
||||
const device = await startDeviceFlow(domain, interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.user_code,
|
||||
verificationUri: device.verification_uri,
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
});
|
||||
|
||||
const githubAccessToken = await pollForGitHubAccessToken(domain, device, interaction.signal);
|
||||
const credentials = await refreshGitHubCopilotAccessToken(
|
||||
githubAccessToken,
|
||||
enterpriseDomain ?? undefined,
|
||||
interaction.signal,
|
||||
);
|
||||
const models = await fetchGitHubCopilotModels(
|
||||
credentials.access,
|
||||
enterpriseDomain ?? undefined,
|
||||
interaction.signal,
|
||||
{
|
||||
maxRetries: 2,
|
||||
maxElapsedMs: 5000,
|
||||
},
|
||||
);
|
||||
let enabledModelIds: string[] = [];
|
||||
if (models.policyModelIds.length > 0) {
|
||||
interaction.notify({ type: "progress", message: "Enabling models..." });
|
||||
enabledModelIds = await enableGitHubCopilotModels(
|
||||
credentials.access,
|
||||
models.policyModelIds,
|
||||
enterpriseDomain ?? undefined,
|
||||
interaction.signal,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...credentials,
|
||||
availableModelIds: [...new Set([...models.availableModelIds, ...enabledModelIds])],
|
||||
};
|
||||
}
|
||||
|
||||
function copilotEnterpriseDomain(credential: OAuthCredential): string | undefined {
|
||||
const enterpriseUrl = credential.enterpriseUrl;
|
||||
if (typeof enterpriseUrl !== "string" || !enterpriseUrl) return undefined;
|
||||
return normalizeDomain(enterpriseUrl) ?? undefined;
|
||||
}
|
||||
|
||||
export const githubCopilotOAuth: OAuthAuth = {
|
||||
name: "GitHub Copilot",
|
||||
isSubscription: true,
|
||||
login: loginGitHubCopilot,
|
||||
refresh: (credential, signal) =>
|
||||
refreshGitHubCopilotToken(credential.refresh, copilotEnterpriseDomain(credential), signal),
|
||||
|
||||
/** Derive the credential-specific proxy endpoint for each request. */
|
||||
async toAuth(credential) {
|
||||
return {
|
||||
apiKey: credential.access,
|
||||
baseUrl: getGitHubCopilotBaseUrl(credential.access, copilotEnterpriseDomain(credential)),
|
||||
};
|
||||
},
|
||||
};
|
||||
296
packages/ai/src/auth/oauth/kimi-coding.ts
Normal file
296
packages/ai/src/auth/oauth/kimi-coding.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Kimi Code (subscription) OAuth flow
|
||||
*
|
||||
* RFC 8628 device authorization grant against https://auth.kimi.com with JSON
|
||||
* responses. The access token authenticates requests to
|
||||
* https://api.kimi.com/coding as an `Authorization: Bearer` header.
|
||||
*/
|
||||
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import { sleep } from "../../utils/sleep.ts";
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
|
||||
const CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
|
||||
const DEFAULT_OAUTH_HOST = "https://auth.kimi.com";
|
||||
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
|
||||
const REQUEST_TIMEOUT_MS = 30 * 1000;
|
||||
const REFRESH_MAX_RETRIES = 3;
|
||||
|
||||
type DeviceAuthorization = {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete: string;
|
||||
intervalSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
function getOauthHost(): string {
|
||||
const override = getProviderEnvValue("KIMI_CODE_OAUTH_HOST") || getProviderEnvValue("KIMI_OAUTH_HOST");
|
||||
return (override || DEFAULT_OAUTH_HOST).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function requestSignal(signal: AbortSignal): AbortSignal {
|
||||
return AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]);
|
||||
}
|
||||
|
||||
function formUrlEncode(fields: Record<string, string>): string {
|
||||
return new URLSearchParams(fields).toString();
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const json = await response.json();
|
||||
return json && typeof json === "object" ? (json as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The verification URI is opened in the user's browser; only http(s) URLs are trusted. */
|
||||
function trustedHttpUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startDeviceAuthorization(oauthHost: string, signal: AbortSignal): Promise<DeviceAuthorization> {
|
||||
const response = await fetch(`${oauthHost}/api/oauth/device_authorization`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formUrlEncode({ client_id: CLIENT_ID }),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Kimi Code device authorization failed with status ${response.status}${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
const json = await readJson(response);
|
||||
const deviceCode = json?.device_code;
|
||||
const userCode = json?.user_code;
|
||||
const verificationUri = json?.verification_uri;
|
||||
const verificationUriComplete = json?.verification_uri_complete;
|
||||
if (
|
||||
typeof deviceCode !== "string" ||
|
||||
typeof userCode !== "string" ||
|
||||
typeof verificationUri !== "string" ||
|
||||
typeof verificationUriComplete !== "string" ||
|
||||
!trustedHttpUrl(verificationUriComplete) ||
|
||||
!trustedHttpUrl(verificationUri)
|
||||
) {
|
||||
throw new Error(`Invalid Kimi Code device authorization response: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
const interval = json?.interval;
|
||||
const expiresIn = json?.expires_in;
|
||||
return {
|
||||
deviceCode,
|
||||
userCode,
|
||||
verificationUri,
|
||||
verificationUriComplete,
|
||||
intervalSeconds:
|
||||
typeof interval === "number" && Number.isFinite(interval) && interval > 0
|
||||
? interval
|
||||
: DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
expiresInSeconds:
|
||||
typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0
|
||||
? expiresIn
|
||||
: DEVICE_CODE_TIMEOUT_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
function parseTokenResponse(json: Record<string, unknown> | null, operation: string): TokenResponse {
|
||||
const accessToken = json?.access_token;
|
||||
const refreshToken = json?.refresh_token;
|
||||
const expiresIn = json?.expires_in;
|
||||
if (
|
||||
typeof accessToken !== "string" ||
|
||||
!accessToken ||
|
||||
typeof refreshToken !== "string" ||
|
||||
!refreshToken ||
|
||||
typeof expiresIn !== "number" ||
|
||||
!Number.isFinite(expiresIn) ||
|
||||
expiresIn <= 0
|
||||
) {
|
||||
throw new Error(`Kimi Code token ${operation} response missing fields: ${JSON.stringify(json)}`);
|
||||
}
|
||||
return {
|
||||
access: accessToken,
|
||||
refresh: refreshToken,
|
||||
expires: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollForToken(
|
||||
oauthHost: string,
|
||||
device: DeviceAuthorization,
|
||||
signal: AbortSignal,
|
||||
): Promise<TokenResponse> {
|
||||
return pollOAuthDeviceCodeFlow<TokenResponse>({
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
waitBeforeFirstPoll: true,
|
||||
signal,
|
||||
poll: async () => {
|
||||
const response = await fetch(`${oauthHost}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formUrlEncode({
|
||||
client_id: CLIENT_ID,
|
||||
device_code: device.deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
|
||||
if (response.status >= 500) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return {
|
||||
status: "failed",
|
||||
message: `Kimi Code device token request failed with status ${response.status}${text ? `: ${text}` : ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
const json = await readJson(response);
|
||||
if (response.ok && typeof json?.access_token === "string") {
|
||||
try {
|
||||
return { status: "complete", value: parseTokenResponse(json, "poll") };
|
||||
} catch (error) {
|
||||
return { status: "failed", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
const error = json?.error;
|
||||
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
||||
if (error === "authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (error === "slow_down") {
|
||||
const interval = json?.interval;
|
||||
return {
|
||||
status: "slow_down",
|
||||
intervalSeconds: typeof interval === "number" && interval > 0 ? interval : undefined,
|
||||
};
|
||||
}
|
||||
if (error === "expired_token") {
|
||||
return { status: "failed", message: "Kimi Code device authorization expired. Please restart login." };
|
||||
}
|
||||
if (error === "access_denied") {
|
||||
return { status: "failed", message: "Kimi Code login was denied." };
|
||||
}
|
||||
return {
|
||||
status: "failed",
|
||||
message: `Kimi Code device token request failed (status ${response.status})${typeof error === "string" ? `: ${error}${description}` : ""}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isRetryableRefreshFailure(response: Response): boolean {
|
||||
return response.status === 429 || response.status >= 500;
|
||||
}
|
||||
|
||||
async function refreshToken(oauthHost: string, refreshTokenValue: string, signal: AbortSignal): Promise<TokenResponse> {
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= REFRESH_MAX_RETRIES; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await sleep(1000 * 2 ** (attempt - 1), signal);
|
||||
}
|
||||
if (signal.aborted) {
|
||||
throw new Error("Kimi Code token refresh aborted");
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${oauthHost}/api/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: formUrlEncode({
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshTokenValue,
|
||||
}),
|
||||
signal: requestSignal(signal),
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
continue;
|
||||
}
|
||||
|
||||
const json = await readJson(response);
|
||||
if (response.ok) {
|
||||
return parseTokenResponse(json, "refresh");
|
||||
}
|
||||
|
||||
// Unauthorized: the stored credential is dead; Models clears it and prompts re-login.
|
||||
if (response.status === 401 || response.status === 403 || json?.error === "invalid_grant") {
|
||||
const description = typeof json?.error_description === "string" ? `: ${json.error_description}` : "";
|
||||
throw new Error(`Kimi Code token refresh unauthorized (status ${response.status})${description}`);
|
||||
}
|
||||
|
||||
if (isRetryableRefreshFailure(response) && attempt < REFRESH_MAX_RETRIES) {
|
||||
lastError = new Error(`Kimi Code token refresh failed with status ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = JSON.stringify(json);
|
||||
throw new Error(`Kimi Code token refresh failed with status ${response.status}${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("Kimi Code token refresh failed");
|
||||
}
|
||||
|
||||
async function loginKimiCoding(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const oauthHost = getOauthHost();
|
||||
const device = await startDeviceAuthorization(oauthHost, interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.userCode,
|
||||
verificationUri: device.verificationUriComplete,
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
});
|
||||
const token = await pollForToken(oauthHost, device, interaction.signal);
|
||||
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
|
||||
}
|
||||
|
||||
export const kimiCodingOAuth: OAuthAuth = {
|
||||
name: "Kimi Code (subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with Kimi Code",
|
||||
|
||||
login: loginKimiCoding,
|
||||
|
||||
refresh: async (credential, signal) => {
|
||||
const token = await refreshToken(getOauthHost(), credential.refresh, signal);
|
||||
return { type: "oauth", access: token.access, refresh: token.refresh, expires: token.expires };
|
||||
},
|
||||
|
||||
async toAuth(credential) {
|
||||
return { headers: { Authorization: `Bearer ${credential.access}` } };
|
||||
},
|
||||
};
|
||||
68
packages/ai/src/auth/oauth/load.ts
Normal file
68
packages/ai/src/auth/oauth/load.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { OAuthAuth } from "../types.ts";
|
||||
|
||||
/**
|
||||
* Loads an OAuth flow module through a variable specifier so bundlers cannot
|
||||
* follow the import into Node-only flow code (`node:http` callback servers,
|
||||
* `node:crypto` PKCE). The `.ts`/`.js` rewrite keeps the trick working from
|
||||
* both source and built output.
|
||||
*/
|
||||
const importOAuthModule = (specifier: string): Promise<unknown> => {
|
||||
const runtimeSpecifier = import.meta.url.endsWith(".js") ? specifier.replace(/\.ts$/, ".js") : specifier;
|
||||
return import(runtimeSpecifier);
|
||||
};
|
||||
|
||||
type OAuthFlowLoaders = {
|
||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
xai: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
radius: (options: { name: string; gateway: string }) => OAuthAuth | Promise<OAuthAuth>;
|
||||
};
|
||||
|
||||
let bundledLoaders: OAuthFlowLoaders | undefined;
|
||||
|
||||
/** Registers statically bundled OAuth flows for standalone Bun binaries. */
|
||||
export function registerBundledOAuthFlowLoaders(loaders: OAuthFlowLoaders): void {
|
||||
bundledLoaders = loaders;
|
||||
}
|
||||
|
||||
export const loadAnthropicOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.anthropic();
|
||||
return ((await importOAuthModule("./anthropic.ts")) as { anthropicOAuth: OAuthAuth }).anthropicOAuth;
|
||||
};
|
||||
|
||||
export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.openaiCodex();
|
||||
return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth;
|
||||
};
|
||||
|
||||
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.githubCopilot();
|
||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||
};
|
||||
|
||||
export const loadOpenRouterOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.openrouter();
|
||||
return ((await importOAuthModule("./openrouter.ts")) as { openRouterOAuth: OAuthAuth }).openRouterOAuth;
|
||||
};
|
||||
|
||||
export const loadKimiCodingOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.kimiCoding();
|
||||
return ((await importOAuthModule("./kimi-coding.ts")) as { kimiCodingOAuth: OAuthAuth }).kimiCodingOAuth;
|
||||
};
|
||||
|
||||
export const loadXaiOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.xai();
|
||||
return ((await importOAuthModule("./xai.ts")) as { xaiOAuth: OAuthAuth }).xaiOAuth;
|
||||
};
|
||||
|
||||
export const loadRadiusOAuth = async (options: { name: string; gateway: string }): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.radius(options);
|
||||
return (
|
||||
(await importOAuthModule("./radius.ts")) as {
|
||||
createRadiusOAuth: (input: { name: string; gateway: string }) => OAuthAuth;
|
||||
}
|
||||
).createRadiusOAuth(options);
|
||||
};
|
||||
109
packages/ai/src/auth/oauth/oauth-page.ts
Normal file
109
packages/ai/src/auth/oauth/oauth-page.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
const LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" aria-hidden="true"><path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/><path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/></svg>`;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function renderPage(options: { title: string; heading: string; message: string; details?: string }): string {
|
||||
const title = escapeHtml(options.title);
|
||||
const heading = escapeHtml(options.heading);
|
||||
const message = escapeHtml(options.message);
|
||||
const details = options.details ? escapeHtml(options.details) : undefined;
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<style>
|
||||
:root {
|
||||
--text: #fafafa;
|
||||
--text-dim: #a1a1aa;
|
||||
--page-bg: #09090b;
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { color-scheme: dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--page-bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
text-align: center;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.logo {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
font-weight: 650;
|
||||
color: var(--text);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--text-dim);
|
||||
font-size: 15px;
|
||||
}
|
||||
.details {
|
||||
margin-top: 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="logo">${LOGO_SVG}</div>
|
||||
<h1>${heading}</h1>
|
||||
<p>${message}</p>
|
||||
${details ? `<div class="details">${details}</div>` : ""}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function oauthSuccessHtml(message: string): string {
|
||||
return renderPage({
|
||||
title: "Authentication successful",
|
||||
heading: "Authentication successful",
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
export function oauthErrorHtml(message: string, details?: string): string {
|
||||
return renderPage({
|
||||
title: "Authentication failed",
|
||||
heading: "Authentication failed",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
}
|
||||
544
packages/ai/src/auth/oauth/openai-codex.ts
Normal file
544
packages/ai/src/auth/oauth/openai-codex.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* OpenAI Codex (ChatGPT OAuth) flow
|
||||
*
|
||||
* NOTE: This module uses Node.js crypto and http for the OAuth callback.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
// NEVER convert to top-level imports - breaks browser/Vite builds
|
||||
let _randomBytes: typeof import("node:crypto").randomBytes | null = null;
|
||||
let _http: typeof import("node:http") | null = null;
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
import("node:crypto").then((m) => {
|
||||
_randomBytes = m.randomBytes;
|
||||
});
|
||||
import("node:http").then((m) => {
|
||||
_http = m;
|
||||
});
|
||||
}
|
||||
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
const AUTH_BASE_URL = "https://auth.openai.com";
|
||||
const AUTHORIZE_URL = `${AUTH_BASE_URL}/oauth/authorize`;
|
||||
const TOKEN_URL = `${AUTH_BASE_URL}/oauth/token`;
|
||||
const REDIRECT_URI = "http://localhost:1455/auth/callback";
|
||||
const DEVICE_USER_CODE_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/usercode`;
|
||||
const DEVICE_TOKEN_URL = `${AUTH_BASE_URL}/api/accounts/deviceauth/token`;
|
||||
const DEVICE_VERIFICATION_URI = `${AUTH_BASE_URL}/codex/device`;
|
||||
const DEVICE_REDIRECT_URI = `${AUTH_BASE_URL}/deviceauth/callback`;
|
||||
const DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60;
|
||||
const OPENAI_CODEX_BROWSER_LOGIN_METHOD = "browser";
|
||||
const OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD = "device_code";
|
||||
const SCOPE = "openid profile email offline_access";
|
||||
const JWT_CLAIM_PATH = "https://api.openai.com/auth";
|
||||
|
||||
type OAuthToken = { access: string; refresh: string; expires: number };
|
||||
type TokenOperation = "exchange" | "refresh";
|
||||
|
||||
function getCallbackHost(): string {
|
||||
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
}
|
||||
|
||||
type DeviceAuthInfo = {
|
||||
deviceAuthId: string;
|
||||
userCode: string;
|
||||
intervalSeconds: number;
|
||||
};
|
||||
|
||||
type DeviceTokenSuccess = {
|
||||
authorizationCode: string;
|
||||
codeVerifier: string;
|
||||
};
|
||||
|
||||
type JwtPayload = {
|
||||
[JWT_CLAIM_PATH]?: {
|
||||
chatgpt_account_id?: string;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function createState(): string {
|
||||
if (!_randomBytes) {
|
||||
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
|
||||
}
|
||||
return _randomBytes(16).toString("hex");
|
||||
}
|
||||
|
||||
function parseAuthorizationInput(input: string): { code?: string; state?: string } {
|
||||
const value = input.trim();
|
||||
if (!value) return {};
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return {
|
||||
code: url.searchParams.get("code") ?? undefined,
|
||||
state: url.searchParams.get("state") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
|
||||
if (value.includes("#")) {
|
||||
const [code, state] = value.split("#", 2);
|
||||
return { code, state };
|
||||
}
|
||||
|
||||
if (value.includes("code=")) {
|
||||
const params = new URLSearchParams(value);
|
||||
return {
|
||||
code: params.get("code") ?? undefined,
|
||||
state: params.get("state") ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { code: value };
|
||||
}
|
||||
|
||||
function decodeJwt(token: string): JwtPayload | null {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const payload = parts[1] ?? "";
|
||||
const decoded = atob(payload);
|
||||
return JSON.parse(decoded) as JwtPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithLoginCancellation(input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetch(input, init);
|
||||
} catch (error) {
|
||||
if (init.signal?.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTokenResponse(response: Response, operation: TokenOperation): Promise<OAuthToken> {
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`OpenAI Codex token ${operation} failed (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const rawJson = await response.json();
|
||||
const json = rawJson as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
} | null;
|
||||
if (!json?.access_token || !json.refresh_token || typeof json.expires_in !== "number") {
|
||||
throw new Error(`OpenAI Codex token ${operation} response missing fields: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires: Date.now() + json.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthToken> {
|
||||
const response = await fetchWithLoginCancellation(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
return readTokenResponse(response, "exchange");
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string, signal: AbortSignal): Promise<OAuthToken> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: CLIENT_ID,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`OpenAI Codex token refresh error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
return readTokenResponse(response, "refresh");
|
||||
}
|
||||
|
||||
async function startOpenAICodexDeviceAuth(signal: AbortSignal): Promise<DeviceAuthInfo> {
|
||||
const response = await fetchWithLoginCancellation(DEVICE_USER_CODE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ client_id: CLIENT_ID }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error(
|
||||
"OpenAI Codex device code login is not enabled for this server. Use browser login or verify the server URL.",
|
||||
);
|
||||
}
|
||||
const responseBody = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`OpenAI Codex device code request failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawJson = await response.json();
|
||||
const json = rawJson as {
|
||||
device_auth_id?: string;
|
||||
user_code?: string;
|
||||
interval?: number | string;
|
||||
} | null;
|
||||
const intervalSeconds = typeof json?.interval === "string" ? Number(json.interval.trim()) : json?.interval;
|
||||
if (
|
||||
!json?.device_auth_id ||
|
||||
!json.user_code ||
|
||||
typeof intervalSeconds !== "number" ||
|
||||
!Number.isFinite(intervalSeconds) ||
|
||||
intervalSeconds < 0
|
||||
) {
|
||||
throw new Error(`Invalid OpenAI Codex device code response: ${JSON.stringify(json)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
deviceAuthId: json.device_auth_id,
|
||||
userCode: json.user_code,
|
||||
intervalSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
async function pollOpenAICodexDeviceAuth(device: DeviceAuthInfo, signal: AbortSignal): Promise<DeviceTokenSuccess> {
|
||||
return pollOAuthDeviceCodeFlow<DeviceTokenSuccess>({
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
|
||||
signal,
|
||||
poll: async () => {
|
||||
const response = await fetchWithLoginCancellation(DEVICE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: device.deviceAuthId,
|
||||
user_code: device.userCode,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const rawJson = await response.json();
|
||||
const json = rawJson as { authorization_code?: string; code_verifier?: string } | null;
|
||||
if (!json?.authorization_code || !json.code_verifier) {
|
||||
return {
|
||||
status: "failed",
|
||||
message: `Invalid OpenAI Codex device auth token response: ${JSON.stringify(json)}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "complete",
|
||||
value: { authorizationCode: json.authorization_code, codeVerifier: json.code_verifier },
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 403 || response.status === 404) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
const responseBody = await response.text().catch(() => "");
|
||||
let errorCode: unknown;
|
||||
try {
|
||||
const json = JSON.parse(responseBody) as { error?: string | { code?: string } } | null;
|
||||
const error = json?.error;
|
||||
errorCode = typeof error === "object" ? error?.code : error;
|
||||
} catch {}
|
||||
|
||||
if (errorCode === "deviceauth_authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (errorCode === "slow_down") {
|
||||
return { status: "slow_down" };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "failed",
|
||||
message: `OpenAI Codex device auth failed with status ${response.status}${responseBody ? `: ${responseBody}` : ""}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createAuthorizationFlow(
|
||||
originator: string = "pi",
|
||||
): Promise<{ verifier: string; state: string; url: string }> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const state = createState();
|
||||
|
||||
const url = new URL(AUTHORIZE_URL);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", CLIENT_ID);
|
||||
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||
url.searchParams.set("scope", SCOPE);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("id_token_add_organizations", "true");
|
||||
url.searchParams.set("codex_cli_simplified_flow", "true");
|
||||
url.searchParams.set("originator", originator);
|
||||
|
||||
return { verifier, state, url: url.toString() };
|
||||
}
|
||||
|
||||
type OAuthServerInfo = {
|
||||
close: () => void;
|
||||
cancelWait: () => void;
|
||||
waitForCode: () => Promise<{ code: string } | null>;
|
||||
};
|
||||
|
||||
function startLocalOAuthServer(state: string): Promise<OAuthServerInfo> {
|
||||
if (!_http) {
|
||||
throw new Error("OpenAI Codex OAuth is only available in Node.js environments");
|
||||
}
|
||||
|
||||
let settleWait: ((value: { code: string } | null) => void) | undefined;
|
||||
const waitForCodePromise = new Promise<{ code: string } | null>((resolve) => {
|
||||
let settled = false;
|
||||
settleWait = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
});
|
||||
|
||||
const server = _http.createServer((req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || "", "http://localhost");
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get("state") !== state) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("State mismatch."));
|
||||
return;
|
||||
}
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("Missing authorization code."));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthSuccessHtml("OpenAI authentication completed. You can close this window."));
|
||||
settleWait?.({ code });
|
||||
} catch {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(oauthErrorHtml("Internal error while processing OAuth callback."));
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server
|
||||
.listen(1455, getCallbackHost(), () => {
|
||||
resolve({
|
||||
close: () => server.close(),
|
||||
cancelWait: () => {
|
||||
settleWait?.(null);
|
||||
},
|
||||
waitForCode: () => waitForCodePromise,
|
||||
});
|
||||
})
|
||||
.on("error", (_err: NodeJS.ErrnoException) => {
|
||||
settleWait?.(null);
|
||||
resolve({
|
||||
close: () => {
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
cancelWait: () => {},
|
||||
waitForCode: async () => null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAccountId(accessToken: string): string | null {
|
||||
const payload = decodeJwt(accessToken);
|
||||
const auth = payload?.[JWT_CLAIM_PATH];
|
||||
const accountId = auth?.chatgpt_account_id;
|
||||
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
||||
}
|
||||
|
||||
function credentialsFromToken(token: OAuthToken): OAuthCredential {
|
||||
const accountId = getAccountId(token.access);
|
||||
if (!accountId) {
|
||||
throw new Error("Failed to extract accountId from token");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCodeForCredentials(
|
||||
code: string,
|
||||
verifier: string,
|
||||
redirectUri: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
return credentialsFromToken(await exchangeAuthorizationCode(code, verifier, redirectUri, signal));
|
||||
}
|
||||
|
||||
async function loginOpenAICodexDeviceCode(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const device = await startOpenAICodexDeviceAuth(interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.userCode,
|
||||
verificationUri: DEVICE_VERIFICATION_URI,
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: DEVICE_CODE_TIMEOUT_SECONDS,
|
||||
});
|
||||
const code = await pollOpenAICodexDeviceAuth(device, interaction.signal);
|
||||
return exchangeAuthorizationCodeForCredentials(
|
||||
code.authorizationCode,
|
||||
code.codeVerifier,
|
||||
DEVICE_REDIRECT_URI,
|
||||
interaction.signal,
|
||||
);
|
||||
}
|
||||
|
||||
async function loginOpenAICodex(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, state, url } = await createAuthorizationFlow();
|
||||
const server = await startLocalOAuthServer(state);
|
||||
const manualAbort = new AbortController();
|
||||
const onAbort = () => server.cancelWait();
|
||||
interaction.signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (interaction.signal.aborted) onAbort();
|
||||
let code: string | undefined;
|
||||
let manualCode: string | undefined;
|
||||
let manualError: Error | undefined;
|
||||
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url,
|
||||
instructions: "A browser window should open. Complete login to finish.",
|
||||
});
|
||||
|
||||
try {
|
||||
const manualPromise = interaction
|
||||
.prompt({
|
||||
type: "manual_code",
|
||||
message: "Complete login in your browser, or paste the authorization code / redirect URL here:",
|
||||
placeholder: REDIRECT_URI,
|
||||
signal: manualAbort.signal,
|
||||
})
|
||||
.then((input) => {
|
||||
manualCode = input;
|
||||
server.cancelWait();
|
||||
})
|
||||
.catch((error) => {
|
||||
manualError = error instanceof Error ? error : new Error(String(error));
|
||||
server.cancelWait();
|
||||
});
|
||||
|
||||
const result = await server.waitForCode();
|
||||
if (manualError) throw manualError;
|
||||
if (result?.code) {
|
||||
code = result.code;
|
||||
} else if (manualCode) {
|
||||
const parsed = parseAuthorizationInput(manualCode);
|
||||
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
|
||||
code = parsed.code;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
await manualPromise;
|
||||
if (manualError) throw manualError;
|
||||
if (manualCode) {
|
||||
const parsed = parseAuthorizationInput(manualCode);
|
||||
if (parsed.state && parsed.state !== state) throw new Error("State mismatch");
|
||||
code = parsed.code;
|
||||
}
|
||||
}
|
||||
|
||||
if (!code) throw new Error("Missing authorization code");
|
||||
return exchangeAuthorizationCodeForCredentials(code, verifier, REDIRECT_URI, interaction.signal);
|
||||
} finally {
|
||||
interaction.signal.removeEventListener("abort", onAbort);
|
||||
manualAbort.abort();
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OpenAI Codex OAuth token
|
||||
*/
|
||||
async function refreshOpenAICodexToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
return credentialsFromToken(await refreshAccessToken(refreshToken, signal));
|
||||
}
|
||||
|
||||
export const openaiCodexOAuth: OAuthAuth = {
|
||||
name: "OpenAI (ChatGPT Plus/Pro)",
|
||||
isSubscription: true,
|
||||
|
||||
async login(interaction) {
|
||||
const method = await interaction.prompt({
|
||||
type: "select",
|
||||
message: "Select OpenAI Codex login method:",
|
||||
options: [
|
||||
{ id: OPENAI_CODEX_BROWSER_LOGIN_METHOD, label: "Browser login (default)" },
|
||||
{ id: OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD, label: "Device code login (headless)" },
|
||||
],
|
||||
});
|
||||
|
||||
if (method === OPENAI_CODEX_DEVICE_CODE_LOGIN_METHOD) {
|
||||
return loginOpenAICodexDeviceCode(interaction);
|
||||
}
|
||||
if (method !== OPENAI_CODEX_BROWSER_LOGIN_METHOD) {
|
||||
throw new Error(`Unknown OpenAI Codex login method: ${method}`);
|
||||
}
|
||||
|
||||
return loginOpenAICodex(interaction);
|
||||
},
|
||||
|
||||
refresh: (credential, signal) => refreshOpenAICodexToken(credential.refresh, signal),
|
||||
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
311
packages/ai/src/auth/oauth/openrouter.ts
Normal file
311
packages/ai/src/auth/oauth/openrouter.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* OpenRouter OAuth PKCE flow.
|
||||
*
|
||||
* OpenRouter exchanges an authorization code for a permanent, user-controlled
|
||||
* API key rather than an expiring access/refresh token pair. The callback is
|
||||
* handled by a one-shot loopback server on an ephemeral port, raced against a
|
||||
* manual prompt so remote/headless sessions can paste the redirect URL when
|
||||
* the browser cannot reach the loopback server.
|
||||
*
|
||||
* NOTE: This module uses Node.js http.createServer for the OAuth callback server.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
const AUTHORIZE_URL = "https://openrouter.ai/auth";
|
||||
const TOKEN_URL = "https://openrouter.ai/api/v1/auth/keys";
|
||||
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const TOKEN_EXCHANGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function getCallbackHost(): string {
|
||||
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type OpenRouterCallbackServer = {
|
||||
callbackUrl: string;
|
||||
/** Stop listening and release timers without settling `waitForCredential`. */
|
||||
close: () => void;
|
||||
/** Hand the login over to manual code entry unless a callback already claimed the exchange. */
|
||||
cancelWait: () => void;
|
||||
/**
|
||||
* Resolves with the credential once a browser callback completes the key
|
||||
* exchange, or with null once `cancelWait` hands the login over to manual
|
||||
* code entry. Rejects on timeout, cancellation, or a failed exchange.
|
||||
*/
|
||||
waitForCredential: () => Promise<OAuthCredential | null>;
|
||||
};
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, html: string): void {
|
||||
response.statusCode = status;
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.setHeader("cache-control", "no-store");
|
||||
response.end(html);
|
||||
}
|
||||
|
||||
function parseAuthorizationInput(input: string): string | undefined {
|
||||
const value = input.trim();
|
||||
if (!value) return undefined;
|
||||
|
||||
try {
|
||||
return new URL(value).searchParams.get("code") ?? undefined;
|
||||
} catch {
|
||||
// not a URL
|
||||
}
|
||||
|
||||
if (value.includes("code=")) {
|
||||
return new URLSearchParams(value).get("code") ?? undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function errorDetail(body: JsonObject): string | undefined {
|
||||
if (typeof body.error_description === "string") return body.error_description;
|
||||
if (typeof body.message === "string") return body.message;
|
||||
if (typeof body.error === "string") return body.error;
|
||||
if (body.error && typeof body.error === "object" && !Array.isArray(body.error)) {
|
||||
const message = (body.error as JsonObject).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
if (signal.aborted) throw new Error("Login cancelled");
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(new Error("OpenRouter OAuth token exchange timed out")),
|
||||
TOKEN_EXCHANGE_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
let response: Response;
|
||||
let body: JsonObject = {};
|
||||
try {
|
||||
response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/json" },
|
||||
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
try {
|
||||
const parsed = (await response.json()) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) body = parsed as JsonObject;
|
||||
} catch {
|
||||
if (response.ok) throw new Error("OpenRouter OAuth returned invalid JSON");
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw new Error("Login cancelled");
|
||||
if (controller.signal.aborted) throw new Error("OpenRouter OAuth token exchange timed out");
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = errorDetail(body);
|
||||
throw new Error(`OpenRouter OAuth key exchange failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
if (typeof body.key !== "string" || body.key.length === 0) {
|
||||
throw new Error('OpenRouter OAuth response carries no "key"');
|
||||
}
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
access: body.key,
|
||||
refresh: "",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
}
|
||||
|
||||
async function startCallbackServer(
|
||||
callbackPath: string,
|
||||
verifier: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OpenRouterCallbackServer> {
|
||||
if (signal.aborted) throw new Error("Login cancelled");
|
||||
const callbackHost = getCallbackHost();
|
||||
let resolveCredential: (credential: OAuthCredential | null) => void = () => {};
|
||||
let rejectCredential: (error: Error) => void = () => {};
|
||||
const credential = new Promise<OAuthCredential | null>((resolve, reject) => {
|
||||
resolveCredential = resolve;
|
||||
rejectCredential = reject;
|
||||
});
|
||||
|
||||
let server: Server;
|
||||
let claimed = false;
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
|
||||
const close = (): void => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (onAbort) signal.removeEventListener("abort", onAbort);
|
||||
server.close();
|
||||
};
|
||||
|
||||
const finish = (result: { credential: OAuthCredential | null } | { error: Error }): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
close();
|
||||
if ("credential" in result) resolveCredential(result.credential);
|
||||
else rejectCredential(result.error);
|
||||
};
|
||||
|
||||
server = createServer((request, response) => {
|
||||
void (async () => {
|
||||
const requestUrl = new URL(request.url ?? "/", `http://${callbackHost}`);
|
||||
if (request.method !== "GET" || requestUrl.pathname !== callbackPath) {
|
||||
sendHtml(response, 404, oauthErrorHtml("OAuth callback route not found."));
|
||||
return;
|
||||
}
|
||||
if (claimed || settled) {
|
||||
sendHtml(response, 409, oauthErrorHtml("This OAuth callback has already been used."));
|
||||
return;
|
||||
}
|
||||
|
||||
const oauthError = requestUrl.searchParams.get("error");
|
||||
if (oauthError) {
|
||||
const description = requestUrl.searchParams.get("error_description") ?? oauthError;
|
||||
sendHtml(response, 400, oauthErrorHtml("OpenRouter authorization was denied.", description));
|
||||
finish({ error: new Error(`OpenRouter authorization failed: ${description}`) });
|
||||
return;
|
||||
}
|
||||
|
||||
const code = requestUrl.searchParams.get("code");
|
||||
if (!code) {
|
||||
sendHtml(response, 400, oauthErrorHtml("OpenRouter returned no authorization code."));
|
||||
return;
|
||||
}
|
||||
claimed = true;
|
||||
|
||||
try {
|
||||
const result = await exchangeAuthorizationCode(code, verifier, signal);
|
||||
sendHtml(response, 200, oauthSuccessHtml("Signed in to OpenRouter. You may now close this page."));
|
||||
finish({ credential: result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown token exchange error";
|
||||
sendHtml(response, 502, oauthErrorHtml("OpenRouter key exchange failed.", message));
|
||||
finish({ error: error instanceof Error ? error : new Error(message) });
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, callbackHost, () => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", (error) => finish({ error }));
|
||||
onAbort = () => finish({ error: new Error("Login cancelled") });
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
close();
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
timeout = setTimeout(() => finish({ error: new Error("OpenRouter OAuth login timed out") }), LOGIN_TIMEOUT_MS);
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
close();
|
||||
throw new Error("Could not determine the OpenRouter OAuth callback port");
|
||||
}
|
||||
|
||||
return {
|
||||
callbackUrl: `http://${callbackHost}:${address.port}${callbackPath}`,
|
||||
close,
|
||||
// A claimed callback is already exchanging its code; let that exchange settle the login.
|
||||
cancelWait: () => {
|
||||
if (!claimed) finish({ credential: null });
|
||||
},
|
||||
waitForCredential: () => credential,
|
||||
};
|
||||
}
|
||||
|
||||
async function loginOpenRouter(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const callbackPath = `/oauth/callback/${crypto.randomUUID()}`;
|
||||
const callback = await startCallbackServer(callbackPath, verifier, interaction.signal);
|
||||
const manualAbort = new AbortController();
|
||||
let manualInput: string | undefined;
|
||||
let manualError: Error | undefined;
|
||||
|
||||
try {
|
||||
const authorizeUrl = new URL(AUTHORIZE_URL);
|
||||
authorizeUrl.search = new URLSearchParams({
|
||||
callback_url: callback.callbackUrl,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
}).toString();
|
||||
|
||||
interaction.notify({
|
||||
type: "progress",
|
||||
message: `Listening for OpenRouter OAuth callback on ${callback.callbackUrl}`,
|
||||
});
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: authorizeUrl.toString(),
|
||||
instructions:
|
||||
"Complete sign-in in your browser. If the browser is on another machine, paste the final redirect URL here.",
|
||||
});
|
||||
|
||||
const manualPromise = interaction
|
||||
.prompt({
|
||||
type: "manual_code",
|
||||
message: "Complete sign-in in your browser, or paste the authorization code / redirect URL here:",
|
||||
placeholder: callback.callbackUrl,
|
||||
signal: manualAbort.signal,
|
||||
})
|
||||
.then((input) => {
|
||||
manualInput = input;
|
||||
callback.cancelWait();
|
||||
})
|
||||
.catch((error) => {
|
||||
manualError = error instanceof Error ? error : new Error(String(error));
|
||||
callback.cancelWait();
|
||||
});
|
||||
|
||||
const credential = await callback.waitForCredential();
|
||||
if (manualError) throw manualError;
|
||||
if (credential) return credential;
|
||||
|
||||
await manualPromise;
|
||||
if (manualError) throw manualError;
|
||||
const code = manualInput ? parseAuthorizationInput(manualInput) : undefined;
|
||||
if (!code) throw new Error("Missing authorization code");
|
||||
interaction.notify({ type: "progress", message: "Exchanging authorization code for an API key..." });
|
||||
return await exchangeAuthorizationCode(code, verifier, interaction.signal);
|
||||
} finally {
|
||||
manualAbort.abort();
|
||||
callback.close();
|
||||
}
|
||||
}
|
||||
|
||||
export const openRouterOAuth: OAuthAuth = {
|
||||
name: "OpenRouter OAuth",
|
||||
loginLabel: "Sign in with OpenRouter",
|
||||
login: loginOpenRouter,
|
||||
async refresh(credential, _signal) {
|
||||
return credential;
|
||||
},
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
34
packages/ai/src/auth/oauth/pkce.ts
Normal file
34
packages/ai/src/auth/oauth/pkce.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* PKCE utilities using Web Crypto API.
|
||||
* Works in both Node.js 20+ and browsers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode bytes as base64url string.
|
||||
*/
|
||||
function base64urlEncode(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PKCE code verifier and challenge.
|
||||
* Uses Web Crypto API for cross-platform compatibility.
|
||||
*/
|
||||
export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
||||
// Generate random verifier
|
||||
const verifierBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(verifierBytes);
|
||||
const verifier = base64urlEncode(verifierBytes);
|
||||
|
||||
// Compute SHA-256 challenge
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const challenge = base64urlEncode(new Uint8Array(hashBuffer));
|
||||
|
||||
return { verifier, challenge };
|
||||
}
|
||||
403
packages/ai/src/auth/oauth/radius.ts
Normal file
403
packages/ai/src/auth/oauth/radius.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Radius gateway OAuth flow.
|
||||
*
|
||||
* Radius is a pi-messages gateway. OAuth client APIs live on the configured
|
||||
* gateway; only the interactive browser authorization endpoint is discovered.
|
||||
* Model catalog loading is owned by the Radius provider.
|
||||
*
|
||||
* NOTE: This module uses node:http for the OAuth callback server.
|
||||
* It is only intended for CLI use, not browser environments.
|
||||
*/
|
||||
|
||||
// NEVER convert to top-level imports - breaks browser/Vite builds
|
||||
let _http: typeof import("node:http") | null = null;
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
import("node:http").then((m) => {
|
||||
_http = m;
|
||||
});
|
||||
}
|
||||
|
||||
import { normalizeRadiusGatewayUrl } from "../../providers/radius-config.ts";
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
const CALLBACK_HOST = "127.0.0.1";
|
||||
const CALLBACK_PORT = 1456;
|
||||
const CALLBACK_PATH = "/oauth/callback";
|
||||
const REDIRECT_URI = `http://${CALLBACK_HOST}:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const TOKEN_EXPIRY_SKEW_MS = 60_000;
|
||||
const LOGIN_METHOD_BROWSER = "browser";
|
||||
const LOGIN_METHOD_DEVICE_CODE = "device-code";
|
||||
const OAUTH_CLIENT_ID = "pi-gateway";
|
||||
const OAUTH_SCOPE = "gateway offline_access";
|
||||
const OAUTH_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
type RadiusOAuthDiscovery = {
|
||||
authorizationEndpoint: string;
|
||||
};
|
||||
|
||||
type DeviceAuthorizationResponse = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
expires_in: number;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
async function loadRadiusOAuthDiscovery(gateway: string, signal: AbortSignal): Promise<RadiusOAuthDiscovery> {
|
||||
const response = await fetch(new URL("/v1/oauth", gateway), {
|
||||
headers: { accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Could not load Radius OAuth config from ${gateway}: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const discovery = (await response.json()) as Partial<RadiusOAuthDiscovery>;
|
||||
if (typeof discovery.authorizationEndpoint !== "string") {
|
||||
throw new Error(`Invalid Radius OAuth config from ${gateway}`);
|
||||
}
|
||||
return { authorizationEndpoint: discovery.authorizationEndpoint };
|
||||
}
|
||||
|
||||
class OAuthResponseError extends Error {
|
||||
readonly status: number;
|
||||
readonly oauthError?: string;
|
||||
|
||||
constructor(status: number, oauthError: string | undefined, description: string | undefined, message: string) {
|
||||
const detail = oauthError
|
||||
? description
|
||||
? `${oauthError}: ${description}`
|
||||
: oauthError
|
||||
: description || String(status);
|
||||
super(`${message}: ${detail}`);
|
||||
this.status = status;
|
||||
this.oauthError = oauthError;
|
||||
}
|
||||
}
|
||||
|
||||
async function readOAuthResponseError(response: Response, message: string): Promise<OAuthResponseError> {
|
||||
const text = await response.text().catch(() => "");
|
||||
let oauthError: string | undefined;
|
||||
let description: string | undefined;
|
||||
|
||||
if (text) {
|
||||
try {
|
||||
const data = JSON.parse(text) as { error?: unknown; error_description?: unknown };
|
||||
oauthError = typeof data.error === "string" ? data.error : undefined;
|
||||
description = typeof data.error_description === "string" ? data.error_description : undefined;
|
||||
} catch {
|
||||
description = text;
|
||||
}
|
||||
}
|
||||
|
||||
return new OAuthResponseError(response.status, oauthError, description, message);
|
||||
}
|
||||
|
||||
async function requestOAuthToken(
|
||||
gateway: string,
|
||||
body: URLSearchParams,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL("/v1/oauth/token", gateway), {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await readOAuthResponseError(response, "Radius OAuth token request failed");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
access: data.access_token,
|
||||
refresh: data.refresh_token,
|
||||
expires: Date.now() + data.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS,
|
||||
scope: data.scope,
|
||||
};
|
||||
}
|
||||
|
||||
type OAuthCallbackServer = {
|
||||
waitForCode(): Promise<string | null>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
function startOAuthCallbackServer(expectedState: string, signal: AbortSignal): Promise<OAuthCallbackServer> {
|
||||
if (!_http) {
|
||||
throw new Error("Radius OAuth is only available in Node.js environments");
|
||||
}
|
||||
|
||||
let settle: (code: string | null) => void = () => {};
|
||||
let settled = false;
|
||||
const wait = new Promise<string | null>((resolve) => {
|
||||
settle = resolve;
|
||||
});
|
||||
const finish = (code: string | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
settle(code);
|
||||
};
|
||||
const onAbort = () => finish(null);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
const sendPage = (response: import("node:http").ServerResponse, status: number, html: string) => {
|
||||
response.statusCode = status;
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.end(html);
|
||||
};
|
||||
|
||||
const server = _http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", REDIRECT_URI);
|
||||
if (url.pathname !== CALLBACK_PATH) {
|
||||
sendPage(response, 404, oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
if (url.searchParams.get("state") !== expectedState) {
|
||||
sendPage(response, 400, oauthErrorHtml("OAuth state mismatch."));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) {
|
||||
sendPage(response, 400, oauthErrorHtml(url.searchParams.get("error_description") ?? error));
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) {
|
||||
sendPage(response, 400, oauthErrorHtml("Missing authorization code."));
|
||||
return;
|
||||
}
|
||||
|
||||
sendPage(response, 200, oauthSuccessHtml("Signed in to Radius. You may now close this page."));
|
||||
finish(code);
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server
|
||||
.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
||||
resolve({
|
||||
waitForCode: () => wait,
|
||||
close: () => {
|
||||
finish(null);
|
||||
server.close();
|
||||
},
|
||||
});
|
||||
})
|
||||
.once("error", () => {
|
||||
finish(null);
|
||||
resolve({ waitForCode: async () => null, close: () => {} });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithBrowser(
|
||||
gateway: string,
|
||||
authorizationEndpoint: string,
|
||||
interaction: ProviderAuthInteraction,
|
||||
): Promise<OAuthCredential> {
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const state = crypto.randomUUID();
|
||||
const authorizeUrl = new URL(authorizationEndpoint);
|
||||
authorizeUrl.search = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: OAUTH_SCOPE,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
handoff: "url",
|
||||
state,
|
||||
}).toString();
|
||||
|
||||
const callbackServer = await startOAuthCallbackServer(state, interaction.signal);
|
||||
interaction.notify({ type: "progress", message: `Listening for OAuth callback on ${REDIRECT_URI}` });
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: authorizeUrl.toString(),
|
||||
instructions: "Continue in your browser.",
|
||||
});
|
||||
|
||||
try {
|
||||
const code = await callbackServer.waitForCode();
|
||||
if (!code) {
|
||||
if (interaction.signal.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
throw new Error("OAuth callback did not complete.");
|
||||
}
|
||||
return await requestOAuthToken(
|
||||
gateway,
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
}),
|
||||
interaction.signal,
|
||||
);
|
||||
} finally {
|
||||
callbackServer.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestDeviceAuthorization(gateway: string, signal: AbortSignal): Promise<DeviceAuthorizationResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL("/v1/oauth/device", gateway), {
|
||||
method: "POST",
|
||||
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ client_id: OAUTH_CLIENT_ID, scope: OAUTH_SCOPE }),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await readOAuthResponseError(response, "Radius OAuth device authorization failed");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Partial<DeviceAuthorizationResponse>;
|
||||
if (!data.device_code || !data.user_code || !data.verification_uri || !data.expires_in) {
|
||||
throw new Error("Radius OAuth device authorization response is missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval,
|
||||
};
|
||||
}
|
||||
|
||||
async function loginWithDeviceCode(gateway: string, interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const device = await requestDeviceAuthorization(gateway, interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.user_code,
|
||||
verificationUri: device.verification_uri,
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
});
|
||||
|
||||
return pollOAuthDeviceCodeFlow<OAuthCredential>({
|
||||
intervalSeconds: device.interval,
|
||||
expiresInSeconds: device.expires_in,
|
||||
signal: interaction.signal,
|
||||
poll: async () => {
|
||||
try {
|
||||
const credentials = await requestOAuthToken(
|
||||
gateway,
|
||||
new URLSearchParams({
|
||||
grant_type: OAUTH_DEVICE_CODE_GRANT_TYPE,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
device_code: device.device_code,
|
||||
}),
|
||||
interaction.signal,
|
||||
);
|
||||
return { status: "complete", value: credentials };
|
||||
} catch (error) {
|
||||
if (!(error instanceof OAuthResponseError)) {
|
||||
throw error;
|
||||
}
|
||||
switch (error.oauthError) {
|
||||
case "authorization_pending":
|
||||
return { status: "pending" };
|
||||
case "slow_down":
|
||||
return { status: "slow_down" };
|
||||
case "expired_token":
|
||||
return { status: "failed", message: "Device authorization expired." };
|
||||
case "access_denied":
|
||||
return { status: "failed", message: "Device authorization was denied." };
|
||||
default:
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RadiusOAuthOptions {
|
||||
name: string;
|
||||
gateway: string;
|
||||
}
|
||||
|
||||
export function createRadiusOAuth(options: RadiusOAuthOptions): OAuthAuth {
|
||||
const gateway = normalizeRadiusGatewayUrl(options.gateway);
|
||||
|
||||
return {
|
||||
name: options.name,
|
||||
|
||||
async login(interaction): Promise<OAuthCredential> {
|
||||
const loginMethod = await interaction.prompt({
|
||||
type: "select",
|
||||
message: `Sign in to ${options.name}:`,
|
||||
options: [
|
||||
{ id: LOGIN_METHOD_BROWSER, label: "Sign in with browser (recommended)" },
|
||||
{
|
||||
id: LOGIN_METHOD_DEVICE_CODE,
|
||||
label: "Sign in with device code (when signing in from another device)",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (loginMethod === LOGIN_METHOD_DEVICE_CODE) {
|
||||
return loginWithDeviceCode(gateway, interaction);
|
||||
}
|
||||
if (loginMethod === LOGIN_METHOD_BROWSER) {
|
||||
const discovery = await loadRadiusOAuthDiscovery(gateway, interaction.signal);
|
||||
return loginWithBrowser(gateway, discovery.authorizationEndpoint, interaction);
|
||||
}
|
||||
throw new Error(`Unknown ${options.name} sign-in method: ${loginMethod}`);
|
||||
},
|
||||
|
||||
async refresh(credential, signal): Promise<OAuthCredential> {
|
||||
const refreshed = await requestOAuthToken(
|
||||
gateway,
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
refresh_token: credential.refresh,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
return refreshed;
|
||||
},
|
||||
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
}
|
||||
239
packages/ai/src/auth/oauth/xai.ts
Normal file
239
packages/ai/src/auth/oauth/xai.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* xAI OAuth device-code flow.
|
||||
*/
|
||||
|
||||
import type { OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
|
||||
const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
||||
const XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
||||
const XAI_DEVICE_CODE_URL = "https://auth.x.ai/oauth2/device/code";
|
||||
const XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token";
|
||||
// Refresh slightly before the reported expiry to avoid using a token that dies mid-request.
|
||||
const REFRESH_SKEW_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
type OAuthHttpResponse = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
body: JsonObject;
|
||||
};
|
||||
|
||||
type XaiDeviceCode = {
|
||||
deviceCode: string;
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
verificationUriComplete?: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
function requiredString(body: JsonObject, field: string): string {
|
||||
const value = body[field];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`Invalid xAI OAuth response field: ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveNumber(body: JsonObject, field: string): number {
|
||||
const value = body[field];
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`Invalid xAI OAuth response field: ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// The verification URI is opened in the user's browser; force it to be an https URL
|
||||
// so a malicious response cannot make `open` launch something else.
|
||||
function validateVerificationUri(raw: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error("Untrusted verification URI in xAI OAuth response");
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw new Error("Untrusted verification URI in xAI OAuth response");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
async function postForm(url: string, fields: Record<string, string>, signal: AbortSignal): Promise<OAuthHttpResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams(fields),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let body: JsonObject;
|
||||
try {
|
||||
const parsed = (await response.json()) as unknown;
|
||||
body = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as JsonObject) : {};
|
||||
} catch {
|
||||
if (signal.aborted) {
|
||||
throw new Error("Login cancelled");
|
||||
}
|
||||
throw new Error(`xAI OAuth returned invalid JSON (HTTP ${response.status})`);
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
function requestFailure(action: string, response: OAuthHttpResponse): Error {
|
||||
const error = typeof response.body.error === "string" ? response.body.error : undefined;
|
||||
const description =
|
||||
typeof response.body.error_description === "string" ? response.body.error_description : undefined;
|
||||
const detail = [error, description].filter(Boolean).join(": ");
|
||||
return new Error(`xAI OAuth ${action} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
function parseDeviceCode(body: JsonObject): XaiDeviceCode {
|
||||
// RFC 8628 allows interval 0 (no minimum wait); fall back to the poller's
|
||||
// default instead of failing on non-positive or malformed values.
|
||||
const interval = body.interval;
|
||||
const intervalSeconds =
|
||||
typeof interval === "number" && Number.isFinite(interval) && interval > 0 ? interval : undefined;
|
||||
const verificationUriComplete =
|
||||
typeof body.verification_uri_complete === "string" && body.verification_uri_complete.length > 0
|
||||
? validateVerificationUri(body.verification_uri_complete)
|
||||
: undefined;
|
||||
return {
|
||||
deviceCode: requiredString(body, "device_code"),
|
||||
userCode: requiredString(body, "user_code"),
|
||||
verificationUri: validateVerificationUri(requiredString(body, "verification_uri")),
|
||||
verificationUriComplete,
|
||||
intervalSeconds,
|
||||
expiresInSeconds: positiveNumber(body, "expires_in"),
|
||||
};
|
||||
}
|
||||
|
||||
function credentialsFromTokenResponse(body: JsonObject, previousRefreshToken?: string): OAuthCredential {
|
||||
const access = requiredString(body, "access_token");
|
||||
// xAI may omit refresh_token on refresh when the token is not rotated.
|
||||
const refresh =
|
||||
body.refresh_token === undefined && previousRefreshToken
|
||||
? previousRefreshToken
|
||||
: requiredString(body, "refresh_token");
|
||||
const expiresInSeconds =
|
||||
body.expires_in === undefined ? DEFAULT_TOKEN_LIFETIME_SECONDS : positiveNumber(body, "expires_in");
|
||||
return {
|
||||
type: "oauth",
|
||||
access,
|
||||
refresh,
|
||||
expires: Date.now() + expiresInSeconds * 1000 - REFRESH_SKEW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestDeviceCode(signal: AbortSignal): Promise<XaiDeviceCode> {
|
||||
const response = await postForm(
|
||||
XAI_DEVICE_CODE_URL,
|
||||
{
|
||||
client_id: XAI_CLIENT_ID,
|
||||
scope: XAI_SCOPE,
|
||||
referrer: "pi",
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw requestFailure("device authorization", response);
|
||||
}
|
||||
return parseDeviceCode(response.body);
|
||||
}
|
||||
|
||||
async function pollForTokens(device: XaiDeviceCode, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
return pollOAuthDeviceCodeFlow<OAuthCredential>({
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
waitBeforeFirstPoll: true,
|
||||
signal,
|
||||
poll: async () => {
|
||||
const response = await postForm(
|
||||
XAI_TOKEN_URL,
|
||||
{
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: XAI_CLIENT_ID,
|
||||
device_code: device.deviceCode,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
return { status: "complete", value: credentialsFromTokenResponse(response.body) };
|
||||
}
|
||||
|
||||
const error = response.body.error;
|
||||
if (error === "authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
if (error === "slow_down") {
|
||||
const interval = response.body.interval;
|
||||
return { status: "slow_down", intervalSeconds: typeof interval === "number" ? interval : undefined };
|
||||
}
|
||||
if (error === "access_denied" || error === "authorization_denied") {
|
||||
return { status: "failed", message: "xAI device authorization was denied" };
|
||||
}
|
||||
if (error === "expired_token") {
|
||||
return { status: "failed", message: "xAI device code expired" };
|
||||
}
|
||||
return { status: "failed", message: requestFailure("device token polling", response).message };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loginXai(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
||||
const device = await requestDeviceCode(interaction.signal);
|
||||
interaction.notify({
|
||||
type: "device_code",
|
||||
userCode: device.userCode,
|
||||
verificationUri: device.verificationUriComplete ?? device.verificationUri,
|
||||
intervalSeconds: device.intervalSeconds,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
});
|
||||
return pollForTokens(device, interaction.signal);
|
||||
}
|
||||
|
||||
async function refreshXaiToken(refreshToken: string, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
const response = await postForm(
|
||||
XAI_TOKEN_URL,
|
||||
{
|
||||
grant_type: "refresh_token",
|
||||
client_id: XAI_CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw requestFailure("token refresh", response);
|
||||
}
|
||||
return credentialsFromTokenResponse(response.body, refreshToken);
|
||||
}
|
||||
|
||||
export const xaiOAuth: OAuthAuth = {
|
||||
name: "xAI (Grok/X subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with SuperGrok or X Premium",
|
||||
login: loginXai,
|
||||
refresh: (credential, signal) => refreshXaiToken(credential.refresh, signal),
|
||||
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
205
packages/ai/src/auth/resolve.ts
Normal file
205
packages/ai/src/auth/resolve.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
import { operationSignal, raceWithAbortSignal } from "../utils/abort.ts";
|
||||
import { formatThrownValue } from "../utils/diagnostics.ts";
|
||||
import type {
|
||||
ApiKeyAuth,
|
||||
ApiKeyCredential,
|
||||
AuthContext,
|
||||
AuthResult,
|
||||
Credential,
|
||||
CredentialStore,
|
||||
OAuthAuth,
|
||||
OAuthCredential,
|
||||
ProviderAuth,
|
||||
} from "./types.ts";
|
||||
|
||||
export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | "stream" | "auth" | "oauth";
|
||||
|
||||
export interface AuthResolutionOverrides {
|
||||
apiKey?: string;
|
||||
env?: ProviderEnv;
|
||||
/** Require this much remaining OAuth-token validity; defaults to five minutes. */
|
||||
minOAuthValidityMs?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class ModelsError extends Error {
|
||||
readonly code: ModelsErrorCode;
|
||||
|
||||
constructor(code: ModelsErrorCode, message: string, options?: { cause?: unknown }) {
|
||||
super(withCauseDetail(message, options?.cause), options);
|
||||
this.name = "ModelsError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** Callers surface `error.message` only, so keep the underlying reason in it. */
|
||||
function withCauseDetail(message: string, cause: unknown): string {
|
||||
if (cause === undefined || cause === null) return message;
|
||||
const detail = formatThrownValue(cause).trim();
|
||||
if (!detail || message.includes(detail)) return message;
|
||||
return `${message}: ${detail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth resolution shared by the `Models` and `ImagesModels` collections.
|
||||
* A stored credential owns the provider: ambient/env is consulted only when
|
||||
* nothing is stored. No silent env fallback after a failed refresh or for a
|
||||
* credential type without a matching handler.
|
||||
*/
|
||||
export function resolveProviderAuth(
|
||||
provider: { id: string; auth: ProviderAuth },
|
||||
credentials: CredentialStore,
|
||||
authContext: AuthContext,
|
||||
overrides?: AuthResolutionOverrides,
|
||||
): Promise<AuthResult | undefined> {
|
||||
const signal = operationSignal(overrides?.signal);
|
||||
return raceWithAbortSignal(
|
||||
resolveProviderAuthWithSignal(provider, credentials, authContext, overrides, signal),
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveProviderAuthWithSignal(
|
||||
provider: { id: string; auth: ProviderAuth },
|
||||
credentials: CredentialStore,
|
||||
authContext: AuthContext,
|
||||
overrides: AuthResolutionOverrides | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthResult | undefined> {
|
||||
signal.throwIfAborted();
|
||||
const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;
|
||||
|
||||
if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
|
||||
return resolveApiKey(
|
||||
requestAuthContext,
|
||||
provider.auth.apiKey,
|
||||
provider.id,
|
||||
{
|
||||
type: "api_key",
|
||||
key: overrides.apiKey,
|
||||
env: overrides.env,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
const stored = await readCredential(credentials, provider.id, signal);
|
||||
if (stored) {
|
||||
if (stored.type === "oauth" && provider.auth.oauth) {
|
||||
return resolveStoredOAuth(
|
||||
credentials,
|
||||
provider.id,
|
||||
provider.auth.oauth,
|
||||
stored,
|
||||
signal,
|
||||
overrides?.minOAuthValidityMs,
|
||||
);
|
||||
}
|
||||
if (stored.type === "api_key" && provider.auth.apiKey) {
|
||||
const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored;
|
||||
return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, credential, signal);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Ambient (env vars, AWS profiles, ADC files).
|
||||
return provider.auth.apiKey
|
||||
? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined, signal)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext {
|
||||
return {
|
||||
env: async (name) => env[name] || (await base.env(name)),
|
||||
fileExists: (path) => base.fileExists(path),
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* OAuth resolution with double-checked locking: tokens with less than five
|
||||
* minutes remaining lock, re-check expiry under the lock, refresh once
|
||||
* globally, and persist the rotated credential before release.
|
||||
*/
|
||||
async function resolveStoredOAuth(
|
||||
credentials: CredentialStore,
|
||||
providerId: string,
|
||||
oauth: OAuthAuth,
|
||||
stored: OAuthCredential,
|
||||
signal: AbortSignal,
|
||||
minOAuthValidityMs?: number,
|
||||
): Promise<AuthResult | undefined> {
|
||||
const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0);
|
||||
const expiresSoon = (credential: OAuthCredential) => Date.now() + minimumValidityMs >= credential.expires;
|
||||
let credential = stored;
|
||||
|
||||
if (expiresSoon(credential)) {
|
||||
// Optimistic check said expired; the authoritative check runs under the lock.
|
||||
let post: Credential | undefined;
|
||||
try {
|
||||
post = await credentials.modify(
|
||||
providerId,
|
||||
async (current) => {
|
||||
if (current?.type !== "oauth") return undefined; // logged out meanwhile
|
||||
if (!expiresSoon(current)) return undefined; // another process/request refreshed
|
||||
try {
|
||||
const refreshSignal = AbortSignal.any([
|
||||
signal,
|
||||
AbortSignal.timeout(DEFAULT_OAUTH_REFRESH_TIMEOUT_MS),
|
||||
]);
|
||||
return await oauth.refresh(current, refreshSignal);
|
||||
} catch (error) {
|
||||
throw new ModelsError("oauth", `OAuth refresh failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ModelsError) throw error;
|
||||
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
if (post?.type !== "oauth") return undefined; // logged out meanwhile
|
||||
credential = post;
|
||||
// The normal five-minute window triggers a refresh but does not impose a
|
||||
// provider contract. Explicit callers (such as bearer-token export) do
|
||||
// require the requested minimum after the refresh.
|
||||
if (minOAuthValidityMs !== undefined && expiresSoon(credential)) {
|
||||
throw new ModelsError("oauth", `OAuth refresh returned a token that expires too soon for ${providerId}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return { auth: await oauth.toAuth(credential), source: "OAuth" };
|
||||
} catch (error) {
|
||||
throw new ModelsError("oauth", `OAuth auth derivation failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveApiKey(
|
||||
authContext: AuthContext,
|
||||
apiKey: ApiKeyAuth,
|
||||
providerId: string,
|
||||
credential: ApiKeyCredential | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthResult | undefined> {
|
||||
try {
|
||||
return await apiKey.resolve({ ctx: authContext, credential, signal });
|
||||
} catch (error) {
|
||||
throw new ModelsError("auth", `API key auth failed for provider ${providerId}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function readCredential(
|
||||
credentials: CredentialStore,
|
||||
providerId: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Credential | undefined> {
|
||||
try {
|
||||
return await credentials.read(providerId, { signal });
|
||||
} catch (error) {
|
||||
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
}
|
||||
240
packages/ai/src/auth/types.ts
Normal file
240
packages/ai/src/auth/types.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { ProviderEnv, ProviderHeaders } from "../types.ts";
|
||||
|
||||
/**
|
||||
* Request auth for a single model request. If a value cannot be expressed as
|
||||
* `apiKey`, `headers`, or `baseUrl`, it is provider config, not auth.
|
||||
*/
|
||||
export interface ModelAuth {
|
||||
apiKey?: string;
|
||||
headers?: ProviderHeaders;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored api-key credential. `env` holds provider-scoped environment/config
|
||||
* values such as Cloudflare account/gateway ids.
|
||||
*/
|
||||
export interface ApiKeyCredential {
|
||||
type: "api_key";
|
||||
key?: string;
|
||||
env?: ProviderEnv;
|
||||
}
|
||||
|
||||
/** OAuth token data returned by extension compatibility flows. */
|
||||
export interface OAuthCredentials {
|
||||
refresh: string;
|
||||
access: string;
|
||||
expires: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Stored canonical OAuth credential. */
|
||||
export interface OAuthCredential extends OAuthCredentials {
|
||||
type: "oauth";
|
||||
}
|
||||
|
||||
/** One type-tagged credential per provider — the shape of today's auth.json. */
|
||||
export type Credential = ApiKeyCredential | OAuthCredential;
|
||||
|
||||
/** Non-secret credential metadata for account/status enumeration. */
|
||||
export interface CredentialInfo {
|
||||
providerId: string;
|
||||
type: Credential["type"];
|
||||
}
|
||||
|
||||
/** Optional cancellation for public auth and credential operations. */
|
||||
export interface AuthOperationOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-owned credential storage, keyed by `Provider.id`, one credential per
|
||||
* provider. `modify` is the only write path, so every mutation is a
|
||||
* serialized read-modify-write; `Models.getAuth()` runs OAuth refresh inside
|
||||
* `modify` so concurrent requests cannot double-refresh a rotated token. The
|
||||
* app persists a credential after login via
|
||||
* `modify(provider.id, async () => credential)`. Login/logout orchestration
|
||||
* is app-owned.
|
||||
*
|
||||
* Error semantics: `read` resolves `undefined` for missing entries. Methods
|
||||
* reject only on storage failure; `Models` wraps such rejections in
|
||||
* `ModelsError` with code "auth". Best-effort stores that serve an in-memory
|
||||
* view and record persistence errors internally (like coding-agent's
|
||||
* AuthStorage) are valid implementations.
|
||||
*/
|
||||
export interface CredentialStore {
|
||||
/**
|
||||
* Read the stored credential, possibly expired. Display/status use;
|
||||
* resolved request auth comes from `Models.getAuth()`.
|
||||
*/
|
||||
read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined>;
|
||||
|
||||
/**
|
||||
* List stored credential metadata without resolving or exposing secrets.
|
||||
* Implementations must not execute configured API-key commands while listing.
|
||||
*/
|
||||
list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]>;
|
||||
|
||||
/**
|
||||
* Serialized write — the only write path. `fn` sees the current credential
|
||||
* because correct writes (refresh, login-during-refresh) depend on it;
|
||||
* return the new credential, or undefined to leave the entry unchanged.
|
||||
* Mutual exclusion per provider id, cross-process too where the backing
|
||||
* store supports it (e.g. a file lock). Resolves with the post-write
|
||||
* credential. Rejections from `fn` propagate.
|
||||
*/
|
||||
modify(
|
||||
providerId: string,
|
||||
fn: (current: Credential | undefined) => Promise<Credential | undefined>,
|
||||
options?: AuthOperationOptions,
|
||||
): Promise<Credential | undefined>;
|
||||
|
||||
/** Remove a credential (logout). Implementations serialize this against `modify`. */
|
||||
delete(providerId: string, options?: AuthOperationOptions): Promise<void>;
|
||||
}
|
||||
|
||||
/** Environment access for auth resolution. Injectable for tests and browsers. */
|
||||
export interface AuthContext {
|
||||
env(name: string): Promise<string | undefined>;
|
||||
/** Check whether a file exists. Supports a leading `~`. Always false in browsers. */
|
||||
fileExists(path: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Result of resolving auth for a model. */
|
||||
export interface AuthResult {
|
||||
auth: ModelAuth;
|
||||
/** Provider-scoped environment/config values resolved from credentials and ambient context. */
|
||||
env?: ProviderEnv;
|
||||
/** Human-readable label for status UI: "ANTHROPIC_API_KEY", "OAuth", "~/.aws/credentials". */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface AuthCheck {
|
||||
source?: string;
|
||||
type: "api_key" | "oauth";
|
||||
}
|
||||
|
||||
export type AuthType = "api_key" | "oauth";
|
||||
|
||||
/**
|
||||
* Prompt shown to the user during login. `signal` lets the flow cancel a
|
||||
* pending prompt when an out-of-band event resolves the step, e.g. a
|
||||
* `manual_code` prompt raced against a callback server, aborted when the
|
||||
* callback wins.
|
||||
*/
|
||||
export type AuthPrompt = { signal?: AbortSignal } & (
|
||||
| { type: "text"; message: string; placeholder?: string }
|
||||
| { type: "secret"; message: string; placeholder?: string }
|
||||
| { type: "select"; message: string; options: readonly { id: string; label: string; description?: string }[] }
|
||||
| { type: "manual_code"; message: string; placeholder?: string }
|
||||
);
|
||||
|
||||
export interface AuthInfoLink {
|
||||
url: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type AuthEvent =
|
||||
| { type: "info"; message: string; links?: readonly AuthInfoLink[] }
|
||||
| { type: "auth_url"; url: string; instructions?: string }
|
||||
| {
|
||||
type: "device_code";
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
| { type: "progress"; message: string };
|
||||
|
||||
/**
|
||||
* Login interaction callbacks serving both api-key and OAuth flows.
|
||||
*
|
||||
* `prompt()` returns the entered/selected string (`select` returns the option
|
||||
* id). Rejects on cancel/abort. `signal` aborts the whole login flow;
|
||||
* per-prompt cancellation uses `AuthPrompt.signal`.
|
||||
*/
|
||||
export interface AuthInteraction {
|
||||
signal?: AbortSignal;
|
||||
|
||||
prompt(prompt: AuthPrompt): Promise<string>;
|
||||
notify(event: AuthEvent): void;
|
||||
}
|
||||
|
||||
/** Normalized interaction passed to provider login implementations. */
|
||||
export type ProviderAuthInteraction = AuthInteraction & { signal: AbortSignal };
|
||||
|
||||
/**
|
||||
* Api-key auth: stored key/provider env plus ambient sources (env vars, AWS
|
||||
* profiles, ADC files). Ambient-only providers omit `login`.
|
||||
*/
|
||||
export interface ApiKeyAuth {
|
||||
/** Display name, e.g. "Anthropic API key". */
|
||||
name: string;
|
||||
|
||||
/** Interactive setup (prompt for key/provider env). Absent = ambient-only. */
|
||||
login?(interaction: ProviderAuthInteraction): Promise<ApiKeyCredential>;
|
||||
|
||||
/**
|
||||
* Optional side-effect-free availability check. Use this when `resolve()` may
|
||||
* execute commands or perform other request-time work. Missing means Models
|
||||
* checks availability by resolving auth.
|
||||
*/
|
||||
check?(input: {
|
||||
ctx: AuthContext;
|
||||
credential?: ApiKeyCredential;
|
||||
signal: AbortSignal;
|
||||
}): Promise<AuthCheck | undefined>;
|
||||
|
||||
/**
|
||||
* Resolve auth from the stored credential and/or ambient sources, merging
|
||||
* per field (`credential.key ?? env("...")`, `credential.env?.NAME ?? env("...")`).
|
||||
* undefined = not configured. Resolution is provider-scoped; model-specific
|
||||
* endpoint preparation happens after auth has been resolved.
|
||||
*/
|
||||
resolve(input: {
|
||||
ctx: AuthContext;
|
||||
credential?: ApiKeyCredential;
|
||||
signal: AbortSignal;
|
||||
}): Promise<AuthResult | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
|
||||
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
|
||||
* auth from whatever credential ends up stored.
|
||||
*/
|
||||
export interface OAuthAuth {
|
||||
/** Display name, e.g. "Anthropic (Claude Pro/Max)". */
|
||||
name: string;
|
||||
|
||||
/** Whether access through this auth method is backed by a provider subscription. */
|
||||
isSubscription?: boolean;
|
||||
|
||||
/** Selector label for the OAuth login option, e.g. "Sign in with SuperGrok or X Premium". */
|
||||
loginLabel?: string;
|
||||
|
||||
login(interaction: ProviderAuthInteraction): Promise<OAuthCredential>;
|
||||
|
||||
/**
|
||||
* Exchange the refresh token. Network call; throws on failure
|
||||
* (invalid_grant etc.). `Models` runs this under the store lock.
|
||||
*/
|
||||
refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential>;
|
||||
|
||||
/**
|
||||
* Side-effect-free derivation of request auth from a valid credential.
|
||||
* Covers per-credential baseUrl (GitHub Copilot). Async so lazy wrappers
|
||||
* can load the implementation on first use.
|
||||
*/
|
||||
toAuth(credential: OAuthCredential): Promise<ModelAuth>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider auth. At least one of `apiKey`/`oauth` must be present: even
|
||||
* ambient-credential providers and keyless local servers provide `apiKey`
|
||||
* auth whose `resolve()` reports whether the provider is configured.
|
||||
*/
|
||||
export interface ProviderAuth {
|
||||
apiKey?: ApiKeyAuth;
|
||||
oauth?: OAuthAuth;
|
||||
}
|
||||
6
packages/ai/src/bedrock-provider.ts
Normal file
6
packages/ai/src/bedrock-provider.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { stream, streamSimple } from "./api/bedrock-converse-stream.ts";
|
||||
|
||||
export const bedrockProviderModule = {
|
||||
stream,
|
||||
streamSimple,
|
||||
};
|
||||
21
packages/ai/src/bun-oauth.ts
Normal file
21
packages/ai/src/bun-oauth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { anthropicOAuth } from "./auth/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
||||
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
|
||||
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
||||
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
|
||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||
import { xaiOAuth } from "./auth/oauth/xai.ts";
|
||||
|
||||
/** Register OAuth flows statically embedded in the standalone Bun binary. */
|
||||
export function registerBunOAuthFlows(): void {
|
||||
registerBundledOAuthFlowLoaders({
|
||||
anthropic: () => anthropicOAuth,
|
||||
openaiCodex: () => openaiCodexOAuth,
|
||||
githubCopilot: () => githubCopilotOAuth,
|
||||
openrouter: () => openRouterOAuth,
|
||||
kimiCoding: () => kimiCodingOAuth,
|
||||
xai: () => xaiOAuth,
|
||||
radius: createRadiusOAuth,
|
||||
});
|
||||
}
|
||||
119
packages/ai/src/cli.ts
Normal file
119
packages/ai/src/cli.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
|
||||
import { builtinProviders } from "./providers/all.ts";
|
||||
|
||||
const AUTH_FILE = "auth.json";
|
||||
const PROVIDERS = builtinProviders().filter(
|
||||
(provider): provider is Provider & { auth: { oauth: NonNullable<Provider["auth"]["oauth"]> } } =>
|
||||
provider.auth.oauth !== undefined,
|
||||
);
|
||||
|
||||
function prompt(rl: ReturnType<typeof createInterface>, question: string): Promise<string> {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
function loadAuth(): Record<string, OAuthCredential> {
|
||||
if (!existsSync(AUTH_FILE)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(AUTH_FILE, "utf-8")) as Record<string, OAuthCredential>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveAuth(auth: Record<string, OAuthCredential>): void {
|
||||
writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async function answerPrompt(rl: ReturnType<typeof createInterface>, authPrompt: AuthPrompt): Promise<string> {
|
||||
if (authPrompt.type === "select") {
|
||||
console.log(`\n${authPrompt.message}`);
|
||||
for (let index = 0; index < authPrompt.options.length; index++) {
|
||||
console.log(` ${index + 1}. ${authPrompt.options[index].label}`);
|
||||
}
|
||||
const choice = Number.parseInt(await prompt(rl, `Enter number (1-${authPrompt.options.length}): `), 10) - 1;
|
||||
const selected = authPrompt.options[choice];
|
||||
if (!selected) throw new Error("Invalid selection");
|
||||
return selected.id;
|
||||
}
|
||||
return prompt(rl, `${authPrompt.message}${authPrompt.placeholder ? ` (${authPrompt.placeholder})` : ""}: `);
|
||||
}
|
||||
|
||||
async function login(providerId: string): Promise<void> {
|
||||
const provider = PROVIDERS.find((entry) => entry.id === providerId);
|
||||
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const credential = await provider.auth.oauth.login({
|
||||
signal: new AbortController().signal,
|
||||
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
|
||||
notify: (event) => {
|
||||
switch (event.type) {
|
||||
case "auth_url":
|
||||
console.log(`\nOpen this URL in your browser:\n${event.url}`);
|
||||
if (event.instructions) console.log(event.instructions);
|
||||
break;
|
||||
case "device_code":
|
||||
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
|
||||
console.log(`Enter code: ${event.userCode}`);
|
||||
break;
|
||||
case "info":
|
||||
case "progress":
|
||||
console.log(event.message);
|
||||
break;
|
||||
}
|
||||
},
|
||||
});
|
||||
const auth = loadAuth();
|
||||
auth[providerId] = credential;
|
||||
saveAuth(auth);
|
||||
console.log(`\nCredentials saved to ${AUTH_FILE}`);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||
const providerList = PROVIDERS.map((provider) => ` ${provider.id.padEnd(20)} ${provider.name}`).join("\n");
|
||||
console.log(
|
||||
`Usage: npx @earendil-works/pi-ai <command> [provider]\n\nCommands:\n login [provider] Login to an OAuth provider\n list List available providers\n\nProviders:\n${providerList}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (command === "list") {
|
||||
for (const provider of PROVIDERS) console.log(`${provider.id.padEnd(20)} ${provider.name}`);
|
||||
return;
|
||||
}
|
||||
if (command === "login") {
|
||||
let providerId = args[1];
|
||||
if (!providerId) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
for (let index = 0; index < PROVIDERS.length; index++) {
|
||||
console.log(` ${index + 1}. ${PROVIDERS[index].name}`);
|
||||
}
|
||||
const index = Number.parseInt(await prompt(rl, `Enter number (1-${PROVIDERS.length}): `), 10) - 1;
|
||||
providerId = PROVIDERS[index]?.id;
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
if (!providerId || !PROVIDERS.some((provider) => provider.id === providerId)) {
|
||||
throw new Error(`Unknown provider: ${providerId ?? ""}`);
|
||||
}
|
||||
await login(providerId);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error("Error:", error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
298
packages/ai/src/compat.ts
Normal file
298
packages/ai/src/compat.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Temporary compatibility entrypoint preserving the old global pi-ai API
|
||||
* surface: api-dispatch `stream()`/`complete()` with env API key injection,
|
||||
* the api-registry, generated catalog reads (`getModel`/`getModels`/
|
||||
* `getProviders`), per-API lazy stream wrappers, and image generation.
|
||||
*
|
||||
* Existing apps switch imports from "@earendil-works/pi-ai" to
|
||||
* "@earendil-works/pi-ai/compat" unchanged; new code uses `createModels()`
|
||||
* and the provider factories. This module is deleted with the coding-agent
|
||||
* ModelManager migration.
|
||||
*/
|
||||
|
||||
export * from "./api/anthropic-messages.lazy.ts";
|
||||
export * from "./api/azure-openai-responses.lazy.ts";
|
||||
export * from "./api/bedrock-converse-stream.lazy.ts";
|
||||
export * from "./api/google-generative-ai.lazy.ts";
|
||||
export * from "./api/google-vertex.lazy.ts";
|
||||
export * from "./api/mistral-conversations.lazy.ts";
|
||||
export * from "./api/openai-codex-responses.lazy.ts";
|
||||
export * from "./api/openai-completions.lazy.ts";
|
||||
export * from "./api/openai-responses.lazy.ts";
|
||||
export * from "./api/pi-messages.lazy.ts";
|
||||
export * from "./env-api-keys.ts";
|
||||
export * from "./image-models.ts";
|
||||
export * from "./images.ts";
|
||||
export * from "./images-api-registry.ts";
|
||||
export * from "./index.ts";
|
||||
export * from "./legacy-api-aliases.ts";
|
||||
export * from "./providers/images/register-builtins.ts";
|
||||
|
||||
import { anthropicMessagesApi } from "./api/anthropic-messages.lazy.ts";
|
||||
import { azureOpenAIResponsesApi } from "./api/azure-openai-responses.lazy.ts";
|
||||
import { bedrockConverseStreamApi } from "./api/bedrock-converse-stream.lazy.ts";
|
||||
import { googleGenerativeAIApi } from "./api/google-generative-ai.lazy.ts";
|
||||
import { googleVertexApi } from "./api/google-vertex.lazy.ts";
|
||||
import { mistralConversationsApi } from "./api/mistral-conversations.lazy.ts";
|
||||
import { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts";
|
||||
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
|
||||
import { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
|
||||
import { piMessagesApi } from "./api/pi-messages.lazy.ts";
|
||||
import { getEnvApiKey } from "./env-api-keys.ts";
|
||||
import type { ModelsApiStreamOptions } from "./models.ts";
|
||||
import { builtinModels, getBuiltinModel, getBuiltinModels, getBuiltinProviders } from "./providers/all.ts";
|
||||
|
||||
export type { BuiltinProvider } from "./providers/all.ts";
|
||||
|
||||
import { createFauxCore, type FauxProviderRegistration, type RegisterFauxProviderOptions } from "./providers/faux.ts";
|
||||
import type {
|
||||
Api,
|
||||
ApiStreamOptions,
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
Context,
|
||||
Model,
|
||||
ProviderStreamOptions,
|
||||
ProviderStreams,
|
||||
SimpleStreamOptions,
|
||||
StreamFunction,
|
||||
StreamOptions,
|
||||
} from "./types.ts";
|
||||
|
||||
/** @deprecated Static catalog read. Use `getBuiltinModel` from "@earendil-works/pi-ai/providers/all" or `Models.getModel()`. */
|
||||
export const getModel = getBuiltinModel;
|
||||
|
||||
/** @deprecated Static catalog read. Use `getBuiltinModels` from "@earendil-works/pi-ai/providers/all" or `Models.getModels()`. */
|
||||
export const getModels = getBuiltinModels;
|
||||
|
||||
/** @deprecated Static catalog read. Use `getBuiltinProviders` from "@earendil-works/pi-ai/providers/all" or `Models.getProviders()`. */
|
||||
export const getProviders = getBuiltinProviders;
|
||||
|
||||
export type ApiStreamFunction = (
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
options?: StreamOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
|
||||
export type ApiStreamSimpleFunction = (
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
) => AssistantMessageEventStream;
|
||||
|
||||
export interface ApiProvider<TApi extends Api = Api, TOptions extends StreamOptions = StreamOptions> {
|
||||
api: TApi;
|
||||
stream: StreamFunction<TApi, TOptions>;
|
||||
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
|
||||
}
|
||||
|
||||
interface ApiProviderInternal {
|
||||
api: Api;
|
||||
stream: ApiStreamFunction;
|
||||
streamSimple: ApiStreamSimpleFunction;
|
||||
}
|
||||
|
||||
type RegisteredApiProvider = {
|
||||
provider: ApiProviderInternal;
|
||||
sourceId?: string;
|
||||
};
|
||||
|
||||
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
|
||||
|
||||
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
|
||||
api: TApi,
|
||||
stream: StreamFunction<TApi, TOptions>,
|
||||
): ApiStreamFunction {
|
||||
return (model, context, options) => {
|
||||
if (model.api !== api) {
|
||||
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
|
||||
}
|
||||
return stream(model as Model<TApi>, context, options as TOptions);
|
||||
};
|
||||
}
|
||||
|
||||
function wrapStreamSimple<TApi extends Api>(
|
||||
api: TApi,
|
||||
streamSimple: StreamFunction<TApi, SimpleStreamOptions>,
|
||||
): ApiStreamSimpleFunction {
|
||||
return (model, context, options) => {
|
||||
if (model.api !== api) {
|
||||
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
|
||||
}
|
||||
return streamSimple(model as Model<TApi>, context, options);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
|
||||
provider: ApiProvider<TApi, TOptions>,
|
||||
sourceId?: string,
|
||||
): void {
|
||||
apiProviderRegistry.set(provider.api, {
|
||||
provider: {
|
||||
api: provider.api,
|
||||
stream: wrapStream(provider.api, provider.stream),
|
||||
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
|
||||
},
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
|
||||
return apiProviderRegistry.get(api)?.provider;
|
||||
}
|
||||
|
||||
export function getApiProviders(): ApiProviderInternal[] {
|
||||
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
|
||||
}
|
||||
|
||||
export function unregisterApiProviders(sourceId: string): void {
|
||||
for (const [api, entry] of apiProviderRegistry.entries()) {
|
||||
if (entry.sourceId === sourceId) {
|
||||
apiProviderRegistry.delete(api);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearApiProviders(): void {
|
||||
apiProviderRegistry.clear();
|
||||
}
|
||||
|
||||
export function registerFauxProvider(options: RegisterFauxProviderOptions = {}): FauxProviderRegistration {
|
||||
const core = createFauxCore(options);
|
||||
const sourceId = `faux-provider-${Math.random().toString(36).slice(2, 10)}`;
|
||||
registerApiProvider({ api: core.api, stream: core.stream, streamSimple: core.streamSimple }, sourceId);
|
||||
return {
|
||||
api: core.api,
|
||||
models: core.models,
|
||||
getModel: core.getModel,
|
||||
state: core.state,
|
||||
setResponses: core.setResponses,
|
||||
appendResponses: core.appendResponses,
|
||||
getPendingResponseCount: core.getPendingResponseCount,
|
||||
unregister() {
|
||||
unregisterApiProviders(sourceId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const BUILTIN_APIS: [Api, ProviderStreams][] = [
|
||||
["anthropic-messages", anthropicMessagesApi()],
|
||||
["openai-completions", openAICompletionsApi()],
|
||||
["openai-responses", openAIResponsesApi()],
|
||||
["openai-codex-responses", openAICodexResponsesApi()],
|
||||
["azure-openai-responses", azureOpenAIResponsesApi()],
|
||||
["google-generative-ai", googleGenerativeAIApi()],
|
||||
["google-vertex", googleVertexApi()],
|
||||
["mistral-conversations", mistralConversationsApi()],
|
||||
["bedrock-converse-stream", bedrockConverseStreamApi()],
|
||||
["pi-messages", piMessagesApi()],
|
||||
];
|
||||
|
||||
const builtinApiProviderInstances = new Map<Api, ReturnType<typeof getApiProvider>>();
|
||||
|
||||
/**
|
||||
* Registers the builtin API implementations into the api-registry without
|
||||
* clobbering existing entries: compat may load after a test or extension has
|
||||
* already registered an override for a builtin api id.
|
||||
*/
|
||||
export function registerBuiltInApiProviders(): void {
|
||||
for (const [api, streams] of BUILTIN_APIS) {
|
||||
if (!getApiProvider(api)) {
|
||||
registerApiProvider({ api, stream: streams.stream, streamSimple: streams.streamSimple });
|
||||
}
|
||||
builtinApiProviderInstances.set(api, getApiProvider(api));
|
||||
}
|
||||
}
|
||||
|
||||
export function resetApiProviders(): void {
|
||||
clearApiProviders();
|
||||
builtinApiProviderInstances.clear();
|
||||
registerBuiltInApiProviders();
|
||||
}
|
||||
|
||||
registerBuiltInApiProviders();
|
||||
|
||||
const compatModels = builtinModels();
|
||||
const AMBIENT_AUTH_MARKER = "<authenticated>";
|
||||
|
||||
function hasExplicitApiKey(apiKey: string | undefined): apiKey is string {
|
||||
return typeof apiKey === "string" && apiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
function withEnvApiKey<TOptions extends StreamOptions>(
|
||||
model: Model<Api>,
|
||||
options: TOptions | undefined,
|
||||
): TOptions | undefined {
|
||||
if (hasExplicitApiKey(options?.apiKey)) return options;
|
||||
const apiKey = getEnvApiKey(model.provider, options?.env);
|
||||
if (!apiKey || apiKey === AMBIENT_AUTH_MARKER) return options;
|
||||
return { ...options, apiKey } as TOptions;
|
||||
}
|
||||
|
||||
function hasResolvedCloudflareAuth(options: StreamOptions | undefined): boolean {
|
||||
return hasExplicitApiKey(options?.apiKey) || typeof options?.headers?.["cf-aig-authorization"] === "string";
|
||||
}
|
||||
|
||||
function getBuiltinProviderForModel(model: Model<Api>) {
|
||||
if (getApiProvider(model.api) !== builtinApiProviderInstances.get(model.api)) return undefined;
|
||||
const provider = compatModels.getProvider(model.provider);
|
||||
return provider?.getModels().some((candidate) => candidate.api === model.api) ? provider : undefined;
|
||||
}
|
||||
|
||||
function resolveApiProvider(api: Api) {
|
||||
const provider = getApiProvider(api);
|
||||
if (!provider) {
|
||||
throw new Error(`No API provider registered for api: ${api}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export function stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ProviderStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const builtinProvider = getBuiltinProviderForModel(model);
|
||||
if (builtinProvider) {
|
||||
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
|
||||
return compatModels.stream(model, context, options as ModelsApiStreamOptions<TApi> | undefined);
|
||||
}
|
||||
return builtinProvider.stream(model, context, withEnvApiKey(model, options) as ApiStreamOptions<TApi>);
|
||||
}
|
||||
const provider = resolveApiProvider(model.api);
|
||||
return provider.stream(model, context, withEnvApiKey(model, options) as StreamOptions);
|
||||
}
|
||||
|
||||
export async function complete<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ProviderStreamOptions,
|
||||
): Promise<AssistantMessage> {
|
||||
const s = stream(model, context, options);
|
||||
return s.result();
|
||||
}
|
||||
|
||||
export function streamSimple<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): AssistantMessageEventStream {
|
||||
const builtinProvider = getBuiltinProviderForModel(model);
|
||||
if (builtinProvider) {
|
||||
if (model.provider.startsWith("cloudflare-") && !hasResolvedCloudflareAuth(options)) {
|
||||
return compatModels.streamSimple(model, context, options);
|
||||
}
|
||||
return builtinProvider.streamSimple(model, context, withEnvApiKey(model, options));
|
||||
}
|
||||
const provider = resolveApiProvider(model.api);
|
||||
return provider.streamSimple(model, context, withEnvApiKey(model, options));
|
||||
}
|
||||
|
||||
export async function completeSimple<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: SimpleStreamOptions,
|
||||
): Promise<AssistantMessage> {
|
||||
const s = streamSimple(model, context, options);
|
||||
return s.result();
|
||||
}
|
||||
45
packages/ai/src/compat/extension-oauth-types.ts
Normal file
45
packages/ai/src/compat/extension-oauth-types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { OAuthCredentials } from "../auth/types.ts";
|
||||
|
||||
/** Legacy extension OAuth prompt. */
|
||||
export interface OAuthPrompt {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
}
|
||||
|
||||
/** Legacy extension OAuth authorization link. */
|
||||
export interface OAuthAuthInfo {
|
||||
url: string;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
/** Legacy extension OAuth device-code notification. */
|
||||
export interface OAuthDeviceCodeInfo {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
export interface OAuthSelectOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface OAuthSelectPrompt {
|
||||
message: string;
|
||||
options: OAuthSelectOption[];
|
||||
}
|
||||
|
||||
/** Callback surface retained only for coding-agent extension compatibility. */
|
||||
export interface OAuthLoginCallbacks {
|
||||
onAuth(info: OAuthAuthInfo): void;
|
||||
onDeviceCode(info: OAuthDeviceCodeInfo): void;
|
||||
onPrompt(prompt: OAuthPrompt): Promise<string>;
|
||||
onProgress?(message: string): void;
|
||||
onManualCodeInput?(): Promise<string>;
|
||||
onSelect(prompt: OAuthSelectPrompt): Promise<string | undefined>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export type { OAuthCredentials };
|
||||
188
packages/ai/src/env-api-keys.ts
Normal file
188
packages/ai/src/env-api-keys.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// NEVER convert to top-level imports - breaks browser/Vite builds
|
||||
let _existsSync: typeof import("node:fs").existsSync | null = null;
|
||||
let _homedir: typeof import("node:os").homedir | null = null;
|
||||
let _join: typeof import("node:path").join | null = null;
|
||||
|
||||
type DynamicImport = (specifier: string) => Promise<unknown>;
|
||||
|
||||
const dynamicImport: DynamicImport = (specifier) => import(specifier);
|
||||
const NODE_FS_SPECIFIER = "node:" + "fs";
|
||||
const NODE_OS_SPECIFIER = "node:" + "os";
|
||||
const NODE_PATH_SPECIFIER = "node:" + "path";
|
||||
|
||||
// Eagerly load in Node.js/Bun environment only
|
||||
if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) {
|
||||
dynamicImport(NODE_FS_SPECIFIER).then((m) => {
|
||||
_existsSync = (m as typeof import("node:fs")).existsSync;
|
||||
});
|
||||
dynamicImport(NODE_OS_SPECIFIER).then((m) => {
|
||||
_homedir = (m as typeof import("node:os")).homedir;
|
||||
});
|
||||
dynamicImport(NODE_PATH_SPECIFIER).then((m) => {
|
||||
_join = (m as typeof import("node:path")).join;
|
||||
});
|
||||
}
|
||||
|
||||
import type { KnownProvider, ProviderEnv } from "./types.ts";
|
||||
import { getProviderEnvValue } from "./utils/provider-env.ts";
|
||||
|
||||
export const ANTHROPIC_AUTH_TOKEN_ENV = "ANTHROPIC_AUTH_TOKEN";
|
||||
export const ANTHROPIC_OAUTH_TOKEN_ENV = "ANTHROPIC_OAUTH_TOKEN";
|
||||
export const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
|
||||
|
||||
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
||||
|
||||
function hasVertexAdcCredentials(env?: ProviderEnv): boolean {
|
||||
const explicitCredentialsPath = env?.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
if (explicitCredentialsPath) {
|
||||
return _existsSync ? _existsSync(explicitCredentialsPath) : false;
|
||||
}
|
||||
|
||||
if (cachedVertexAdcCredentialsExists === null) {
|
||||
// If node modules haven't loaded yet (async import race at startup),
|
||||
// return false WITHOUT caching so the next call retries once they're ready.
|
||||
// Only cache false permanently in a browser environment where fs is never available.
|
||||
if (!_existsSync || !_homedir || !_join) {
|
||||
const isNode = typeof process !== "undefined" && (process.versions?.node || process.versions?.bun);
|
||||
if (!isNode) {
|
||||
// Definitively in a browser — safe to cache false permanently
|
||||
cachedVertexAdcCredentialsExists = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way)
|
||||
const gacPath = getProviderEnvValue("GOOGLE_APPLICATION_CREDENTIALS", env);
|
||||
if (gacPath) {
|
||||
cachedVertexAdcCredentialsExists = _existsSync(gacPath);
|
||||
} else {
|
||||
// Fall back to default ADC path (lazy evaluation)
|
||||
cachedVertexAdcCredentialsExists = _existsSync(
|
||||
_join(_homedir(), ".config", "gcloud", "application_default_credentials.json"),
|
||||
);
|
||||
}
|
||||
}
|
||||
return cachedVertexAdcCredentialsExists;
|
||||
}
|
||||
|
||||
function getApiKeyEnvVars(provider: string): readonly string[] | undefined {
|
||||
if (provider === "github-copilot") {
|
||||
return ["COPILOT_GITHUB_TOKEN"];
|
||||
}
|
||||
|
||||
// ANTHROPIC_AUTH_TOKEN participates in env discovery/status, but
|
||||
// getEnvApiKey() skips it because requests must pass it as Authorization: Bearer.
|
||||
if (provider === "anthropic") {
|
||||
return [ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV];
|
||||
}
|
||||
|
||||
const envMap: Record<string, string> = {
|
||||
"ant-ling": "ANT_LING_API_KEY",
|
||||
"qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY",
|
||||
"qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY",
|
||||
"qwen-token-plan-individual": "QWEN_TOKEN_PLAN_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
"azure-openai-responses": "AZURE_OPENAI_API_KEY",
|
||||
nvidia: "NVIDIA_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
google: "GEMINI_API_KEY",
|
||||
"google-vertex": "GOOGLE_CLOUD_API_KEY",
|
||||
groq: "GROQ_API_KEY",
|
||||
cerebras: "CEREBRAS_API_KEY",
|
||||
xai: "XAI_API_KEY",
|
||||
radius: "RADIUS_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
"vercel-ai-gateway": "AI_GATEWAY_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
"zai-coding-cn": "ZAI_CODING_CN_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
"minimax-cn": "MINIMAX_CN_API_KEY",
|
||||
moonshotai: "MOONSHOT_API_KEY",
|
||||
"moonshotai-cn": "MOONSHOT_API_KEY",
|
||||
huggingface: "HF_TOKEN",
|
||||
fireworks: "FIREWORKS_API_KEY",
|
||||
together: "TOGETHER_API_KEY",
|
||||
baseten: "BASETEN_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
"opencode-go": "OPENCODE_API_KEY",
|
||||
"kimi-coding": "KIMI_API_KEY",
|
||||
"cloudflare-workers-ai": "CLOUDFLARE_API_KEY",
|
||||
"cloudflare-ai-gateway": "CLOUDFLARE_API_KEY",
|
||||
xiaomi: "XIAOMI_API_KEY",
|
||||
"xiaomi-token-plan-cn": "XIAOMI_TOKEN_PLAN_CN_API_KEY",
|
||||
"xiaomi-token-plan-ams": "XIAOMI_TOKEN_PLAN_AMS_API_KEY",
|
||||
"xiaomi-token-plan-sgp": "XIAOMI_TOKEN_PLAN_SGP_API_KEY",
|
||||
};
|
||||
|
||||
const envVar = envMap[provider];
|
||||
return envVar ? [envVar] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find configured environment variables that can provide an API key for a provider.
|
||||
*
|
||||
* This only reports actual API key variables. It intentionally excludes ambient
|
||||
* credential sources such as AWS profiles, AWS IAM credentials, and Google
|
||||
* Application Default Credentials.
|
||||
*/
|
||||
export function findEnvKeys(provider: KnownProvider, env?: ProviderEnv): string[] | undefined;
|
||||
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined;
|
||||
export function findEnvKeys(provider: string, env?: ProviderEnv): string[] | undefined {
|
||||
const envVars = getApiKeyEnvVars(provider);
|
||||
if (!envVars) return undefined;
|
||||
|
||||
const found = envVars.filter((envVar) => !!getProviderEnvValue(envVar, env));
|
||||
return found.length > 0 ? found : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key for provider from known environment variables, e.g. OPENAI_API_KEY.
|
||||
*
|
||||
* Will not return API keys for providers that require OAuth tokens.
|
||||
*/
|
||||
export function getEnvApiKey(provider: KnownProvider, env?: ProviderEnv): string | undefined;
|
||||
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined;
|
||||
export function getEnvApiKey(provider: string, env?: ProviderEnv): string | undefined {
|
||||
const envKeys = findEnvKeys(provider, env);
|
||||
if (envKeys?.[0]) {
|
||||
const apiKeyEnv = provider === "anthropic" ? envKeys.find((key) => key !== ANTHROPIC_AUTH_TOKEN_ENV) : envKeys[0];
|
||||
if (apiKeyEnv) return getProviderEnvValue(apiKeyEnv, env);
|
||||
}
|
||||
|
||||
// Vertex AI supports either an explicit API key or Application Default Credentials.
|
||||
// Auth is configured via `gcloud auth application-default login`.
|
||||
if (provider === "google-vertex") {
|
||||
const hasCredentials = hasVertexAdcCredentials(env);
|
||||
const hasProject = !!(
|
||||
getProviderEnvValue("GOOGLE_CLOUD_PROJECT", env) || getProviderEnvValue("GCLOUD_PROJECT", env)
|
||||
);
|
||||
const hasLocation = !!getProviderEnvValue("GOOGLE_CLOUD_LOCATION", env);
|
||||
|
||||
if (hasCredentials && hasProject && hasLocation) {
|
||||
return "<authenticated>";
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
// Amazon Bedrock supports multiple credential sources:
|
||||
// 1. AWS_PROFILE - named profile from ~/.aws/credentials
|
||||
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
|
||||
// 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token
|
||||
// 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles
|
||||
// 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI)
|
||||
// 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts)
|
||||
if (
|
||||
getProviderEnvValue("AWS_PROFILE", env) ||
|
||||
(getProviderEnvValue("AWS_ACCESS_KEY_ID", env) && getProviderEnvValue("AWS_SECRET_ACCESS_KEY", env)) ||
|
||||
getProviderEnvValue("AWS_BEARER_TOKEN_BEDROCK", env) ||
|
||||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", env) ||
|
||||
getProviderEnvValue("AWS_CONTAINER_CREDENTIALS_FULL_URI", env) ||
|
||||
getProviderEnvValue("AWS_WEB_IDENTITY_TOKEN_FILE", env)
|
||||
) {
|
||||
return "<authenticated>";
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
789
packages/ai/src/image-models.generated.ts
Normal file
789
packages/ai/src/image-models.generated.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
// This file is auto-generated by scripts/generate-image-models.ts
|
||||
// Do not edit manually - run 'npm run generate-image-models' to update
|
||||
|
||||
import type { ImagesApi, ImagesModel } from "./types.ts";
|
||||
|
||||
export const IMAGE_MODELS = {
|
||||
openrouter: {
|
||||
"black-forest-labs/flux.2-flex": {
|
||||
id: "black-forest-labs/flux.2-flex",
|
||||
name: "Black Forest Labs: FLUX.2 Flex",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-klein-4b": {
|
||||
id: "black-forest-labs/flux.2-klein-4b",
|
||||
name: "Black Forest Labs: FLUX.2 Klein 4B",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-max": {
|
||||
id: "black-forest-labs/flux.2-max",
|
||||
name: "Black Forest Labs: FLUX.2 Max",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"black-forest-labs/flux.2-pro": {
|
||||
id: "black-forest-labs/flux.2-pro",
|
||||
name: "Black Forest Labs: FLUX.2 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-4.5": {
|
||||
id: "bytedance-seed/seedream-4.5",
|
||||
name: "ByteDance Seed: Seedream 4.5",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-5-0-lite": {
|
||||
id: "bytedance-seed/seedream-5-0-lite",
|
||||
name: "ByteDance Seed: Seedream 5.0 Lite",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"bytedance-seed/seedream-5-0-pro": {
|
||||
id: "bytedance-seed/seedream-5-0-pro",
|
||||
name: "ByteDance Seed: Seedream 5.0 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-2.5-flash-image": {
|
||||
id: "google/gemini-2.5-flash-image",
|
||||
name: "Google: Nano Banana (Gemini 2.5 Flash Image)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.3,
|
||||
output: 2.5,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.0833333333333333,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3-pro-image": {
|
||||
id: "google/gemini-3-pro-image",
|
||||
name: "Google: Nano Banana Pro (Gemini 3 Pro Image)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 0.375,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3-pro-image-preview": {
|
||||
id: "google/gemini-3-pro-image-preview",
|
||||
name: "Google: Nano Banana Pro (Gemini 3 Pro Image Preview)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2,
|
||||
output: 12,
|
||||
cacheRead: 0.19999999999999998,
|
||||
cacheWrite: 0.375,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-image": {
|
||||
id: "google/gemini-3.1-flash-image",
|
||||
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-image-preview": {
|
||||
id: "google/gemini-3.1-flash-image-preview",
|
||||
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.5,
|
||||
output: 3,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"google/gemini-3.1-flash-lite-image": {
|
||||
id: "google/gemini-3.1-flash-lite-image",
|
||||
name: "Google: Nano Banana 2 Lite (Gemini 3.1 Flash Lite Image)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 0.25,
|
||||
output: 1.5,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"krea/krea-2-large": {
|
||||
id: "krea/krea-2-large",
|
||||
name: "Krea: Krea 2 Large",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"krea/krea-2-medium": {
|
||||
id: "krea/krea-2-medium",
|
||||
name: "Krea: Krea 2 Medium",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"krea/krea-2-medium-turbo": {
|
||||
id: "krea/krea-2-medium-turbo",
|
||||
name: "Krea: Krea 2 Medium Turbo",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"meta/muse-image": {
|
||||
id: "meta/muse-image",
|
||||
name: "Meta: Muse Image",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.5": {
|
||||
id: "microsoft/mai-image-2.5",
|
||||
name: "Microsoft: MAI-Image-2.5",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.5-pro": {
|
||||
id: "microsoft/mai-image-2.5-pro",
|
||||
name: "Microsoft: MAI-Image-2.5 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.6": {
|
||||
id: "microsoft/mai-image-2.6",
|
||||
name: "Microsoft: MAI-Image-2.6",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 5,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"microsoft/mai-image-2.6-flash": {
|
||||
id: "microsoft/mai-image-2.6-flash",
|
||||
name: "Microsoft: MAI-Image-2.6 Flash",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 1.75,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image": {
|
||||
id: "openai/gpt-5-image",
|
||||
name: "OpenAI: GPT-5 Image",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 1.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5-image-mini": {
|
||||
id: "openai/gpt-5-image-mini",
|
||||
name: "OpenAI: GPT-5 Image Mini",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 2,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-5.4-image-2": {
|
||||
id: "openai/gpt-5.4-image-2",
|
||||
name: "OpenAI: GPT-5.4 Image 2",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["image", "text"],
|
||||
output: ["image", "text"],
|
||||
cost: {
|
||||
input: 8,
|
||||
output: 15,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-image-1": {
|
||||
id: "openai/gpt-image-1",
|
||||
name: "OpenAI: GPT Image 1",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 10,
|
||||
output: 10,
|
||||
cacheRead: 1.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-image-1-mini": {
|
||||
id: "openai/gpt-image-1-mini",
|
||||
name: "OpenAI: GPT Image 1 Mini",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 2.5,
|
||||
output: 2.5,
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openai/gpt-image-2": {
|
||||
id: "openai/gpt-image-2",
|
||||
name: "OpenAI: GPT Image 2",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 8,
|
||||
output: 8,
|
||||
cacheRead: 2,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openrouter/auto": {
|
||||
id: "openrouter/auto",
|
||||
name: "Auto Router",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["text", "image"],
|
||||
cost: {
|
||||
input: -1000000,
|
||||
output: -1000000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"openrouter/auto-beta": {
|
||||
id: "openrouter/auto-beta",
|
||||
name: "Auto Router (Beta)",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["text", "image"],
|
||||
cost: {
|
||||
input: -1000000,
|
||||
output: -1000000,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"qwen/qwen-image-3": {
|
||||
id: "qwen/qwen-image-3",
|
||||
name: "Qwen: Qwen Image 3",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"qwen/qwen-image-3-pro": {
|
||||
id: "qwen/qwen-image-3-pro",
|
||||
name: "Qwen: Qwen Image 3 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v3": {
|
||||
id: "recraft/recraft-v3",
|
||||
name: "Recraft: Recraft V3",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4": {
|
||||
id: "recraft/recraft-v4",
|
||||
name: "Recraft: Recraft V4",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro": {
|
||||
id: "recraft/recraft-v4-pro",
|
||||
name: "Recraft: Recraft V4 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-pro-vector": {
|
||||
id: "recraft/recraft-v4-pro-vector",
|
||||
name: "Recraft: Recraft V4 Pro Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-styles": {
|
||||
id: "recraft/recraft-v4-styles",
|
||||
name: "Recraft: Recraft V4 Styles",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-styles-pro": {
|
||||
id: "recraft/recraft-v4-styles-pro",
|
||||
name: "Recraft: Recraft V4 Styles Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-styles-pro-vector": {
|
||||
id: "recraft/recraft-v4-styles-pro-vector",
|
||||
name: "Recraft: Recraft V4 Styles Pro Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-styles-vector": {
|
||||
id: "recraft/recraft-v4-styles-vector",
|
||||
name: "Recraft: Recraft V4 Styles Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4-vector": {
|
||||
id: "recraft/recraft-v4-vector",
|
||||
name: "Recraft: Recraft V4 Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1": {
|
||||
id: "recraft/recraft-v4.1",
|
||||
name: "Recraft: Recraft V4.1",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-pro": {
|
||||
id: "recraft/recraft-v4.1-pro",
|
||||
name: "Recraft: Recraft V4.1 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-pro-vector": {
|
||||
id: "recraft/recraft-v4.1-pro-vector",
|
||||
name: "Recraft: Recraft V4.1 Pro Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-utility": {
|
||||
id: "recraft/recraft-v4.1-utility",
|
||||
name: "Recraft: Recraft V4.1 Utility",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-utility-pro": {
|
||||
id: "recraft/recraft-v4.1-utility-pro",
|
||||
name: "Recraft: Recraft V4.1 Utility Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"recraft/recraft-v4.1-vector": {
|
||||
id: "recraft/recraft-v4.1-vector",
|
||||
name: "Recraft: Recraft V4.1 Vector",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-fast": {
|
||||
id: "sourceful/riverflow-v2-fast",
|
||||
name: "Sourceful: Riverflow V2 Fast",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2-pro": {
|
||||
id: "sourceful/riverflow-v2-pro",
|
||||
name: "Sourceful: Riverflow V2 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2.5-fast": {
|
||||
id: "sourceful/riverflow-v2.5-fast",
|
||||
name: "Sourceful: Riverflow V2.5 Fast",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"sourceful/riverflow-v2.5-pro": {
|
||||
id: "sourceful/riverflow-v2.5-pro",
|
||||
name: "Sourceful: Riverflow V2.5 Pro",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"x-ai/grok-imagine-image-2.0": {
|
||||
id: "x-ai/grok-imagine-image-2.0",
|
||||
name: "xAI: Grok Imagine Image 2.0",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
"x-ai/grok-imagine-image-quality": {
|
||||
id: "x-ai/grok-imagine-image-quality",
|
||||
name: "SpaceXAI: Grok Imagine Image Quality",
|
||||
api: "openrouter-images",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
input: ["text", "image"],
|
||||
output: ["image"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
} satisfies ImagesModel<"openrouter-images">,
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, ImagesModel<ImagesApi>>>;
|
||||
42
packages/ai/src/image-models.ts
Normal file
42
packages/ai/src/image-models.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IMAGE_MODELS } from "./image-models.generated.ts";
|
||||
import type { ImagesApi, ImagesModel, KnownImagesProvider } from "./types.ts";
|
||||
|
||||
const imageModelRegistry: Map<string, Map<string, ImagesModel<ImagesApi>>> = new Map();
|
||||
|
||||
for (const [provider, models] of Object.entries(IMAGE_MODELS)) {
|
||||
const providerModels = new Map<string, ImagesModel<ImagesApi>>();
|
||||
for (const [id, model] of Object.entries(models)) {
|
||||
providerModels.set(id, model as ImagesModel<ImagesApi>);
|
||||
}
|
||||
imageModelRegistry.set(provider, providerModels);
|
||||
}
|
||||
|
||||
type ImageModelApi<
|
||||
TProvider extends KnownImagesProvider,
|
||||
TModelId extends keyof (typeof IMAGE_MODELS)[TProvider],
|
||||
> = (typeof IMAGE_MODELS)[TProvider][TModelId] extends { api: infer TApi }
|
||||
? TApi extends ImagesApi
|
||||
? TApi
|
||||
: never
|
||||
: never;
|
||||
|
||||
export function getImageModel<
|
||||
TProvider extends KnownImagesProvider,
|
||||
TModelId extends keyof (typeof IMAGE_MODELS)[TProvider],
|
||||
>(provider: TProvider, modelId: TModelId): ImagesModel<ImageModelApi<TProvider, TModelId>> {
|
||||
const providerModels = imageModelRegistry.get(provider);
|
||||
return providerModels?.get(modelId as string) as ImagesModel<ImageModelApi<TProvider, TModelId>>;
|
||||
}
|
||||
|
||||
export function getImageProviders(): KnownImagesProvider[] {
|
||||
return Array.from(imageModelRegistry.keys()) as KnownImagesProvider[];
|
||||
}
|
||||
|
||||
export function getImageModels<TProvider extends KnownImagesProvider>(
|
||||
provider: TProvider,
|
||||
): ImagesModel<ImageModelApi<TProvider, keyof (typeof IMAGE_MODELS)[TProvider]>>[] {
|
||||
const models = imageModelRegistry.get(provider);
|
||||
return models
|
||||
? (Array.from(models.values()) as ImagesModel<ImageModelApi<TProvider, keyof (typeof IMAGE_MODELS)[TProvider]>>[])
|
||||
: [];
|
||||
}
|
||||
53
packages/ai/src/images-api-registry.ts
Normal file
53
packages/ai/src/images-api-registry.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesFunction, ImagesModel, ImagesOptions } from "./types.ts";
|
||||
|
||||
export type ImagesApiFunction = (
|
||||
model: ImagesModel<ImagesApi>,
|
||||
context: ImagesContext,
|
||||
options?: ImagesOptions,
|
||||
) => Promise<AssistantImages>;
|
||||
|
||||
export interface ImagesApiProvider<TApi extends ImagesApi = ImagesApi, TOptions extends ImagesOptions = ImagesOptions> {
|
||||
api: TApi;
|
||||
generateImages: ImagesFunction<TApi, TOptions>;
|
||||
}
|
||||
|
||||
interface ImagesApiProviderInternal {
|
||||
api: ImagesApi;
|
||||
generateImages: ImagesApiFunction;
|
||||
}
|
||||
|
||||
type RegisteredImagesApiProvider = {
|
||||
provider: ImagesApiProviderInternal;
|
||||
sourceId?: string;
|
||||
};
|
||||
|
||||
const imagesApiProviderRegistry = new Map<string, RegisteredImagesApiProvider>();
|
||||
|
||||
function wrapGenerateImages<TApi extends ImagesApi, TOptions extends ImagesOptions>(
|
||||
api: TApi,
|
||||
generateImages: ImagesFunction<TApi, TOptions>,
|
||||
): ImagesApiFunction {
|
||||
return (model, context, options) => {
|
||||
if (model.api !== api) {
|
||||
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
|
||||
}
|
||||
return generateImages(model as ImagesModel<TApi>, context, options as TOptions);
|
||||
};
|
||||
}
|
||||
|
||||
export function registerImagesApiProvider<TApi extends ImagesApi, TOptions extends ImagesOptions>(
|
||||
provider: ImagesApiProvider<TApi, TOptions>,
|
||||
sourceId?: string,
|
||||
): void {
|
||||
imagesApiProviderRegistry.set(provider.api, {
|
||||
provider: {
|
||||
api: provider.api,
|
||||
generateImages: wrapGenerateImages(provider.api, provider.generateImages),
|
||||
},
|
||||
sourceId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getImagesApiProvider(api: ImagesApi): ImagesApiProviderInternal | undefined {
|
||||
return imagesApiProviderRegistry.get(api)?.provider;
|
||||
}
|
||||
275
packages/ai/src/images-models.ts
Normal file
275
packages/ai/src/images-models.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
|
||||
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
|
||||
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
|
||||
import type { AuthContext, AuthResult, CredentialStore, ProviderAuth } from "./auth/types.ts";
|
||||
import type { CreateModelsOptions } from "./models.ts";
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ImagesOptions, ProviderImages } from "./types.ts";
|
||||
|
||||
/**
|
||||
* An image-generation provider: the image-side counterpart of `Provider`.
|
||||
* Owns id/name metadata, auth, model listing, and generation behavior.
|
||||
*/
|
||||
export interface ImagesProvider {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* Required: at least one of `apiKey`/`oauth`. Same semantics as chat
|
||||
* providers; `ImagesModels.getAuth()` returns undefined when the provider
|
||||
* is unconfigured.
|
||||
*/
|
||||
readonly auth: ProviderAuth;
|
||||
|
||||
/**
|
||||
* Current known models, sync. Static providers return their catalog;
|
||||
* dynamic providers return the list as of the last `refreshModels()`
|
||||
* (empty before the first). Must not throw; `ImagesModels` treats a
|
||||
* throwing implementation as having no models.
|
||||
*/
|
||||
getModels(): readonly ImagesModel<ImagesApi>[];
|
||||
|
||||
/**
|
||||
* Dynamic providers only: fetch and update the model list. May reject
|
||||
* (network); on rejection the model list stays at its last-known state
|
||||
* and a later call retries.
|
||||
*/
|
||||
refreshModels?(): Promise<void>;
|
||||
|
||||
generateImages(
|
||||
model: ImagesModel<ImagesApi>,
|
||||
context: ImagesContext,
|
||||
options?: ImagesOptions,
|
||||
): Promise<AssistantImages>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime collection of image-generation providers plus auth application and
|
||||
* generation convenience: the image-side counterpart of `Models`.
|
||||
*/
|
||||
export interface ImagesModels {
|
||||
getProviders(): readonly ImagesProvider[];
|
||||
getProvider(id: string): ImagesProvider | undefined;
|
||||
|
||||
/**
|
||||
* Sync read of last-known models from one provider or all providers.
|
||||
* Best-effort: a provider whose `getModels()` throws yields no models.
|
||||
*/
|
||||
getModels(provider?: string): readonly ImagesModel<ImagesApi>[];
|
||||
|
||||
/** Sync runtime model lookup against last-known lists. */
|
||||
getModel(provider: string, id: string): ImagesModel<ImagesApi> | undefined;
|
||||
|
||||
/**
|
||||
* Ask dynamic providers to re-fetch their model lists. With a provider id,
|
||||
* rejects with `ModelsError` ("model_source") on that provider's fetch
|
||||
* failure; without one, refreshes all providers concurrently best-effort.
|
||||
* Static providers (no `refreshModels`) are no-ops.
|
||||
*/
|
||||
refresh(provider?: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resolve request auth by provider id or image model. Same contract as
|
||||
* `Models.getAuth()`: undefined when unknown/unconfigured, rejects with
|
||||
* `ModelsError` ("oauth"/"auth") on real failures.
|
||||
*/
|
||||
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
|
||||
/**
|
||||
* Generate images through the owning provider with auth resolved and
|
||||
* merged (explicit options win per field). Never rejects; failures are
|
||||
* returned as an `AssistantImages` with `stopReason: "error"`.
|
||||
*/
|
||||
generateImages(
|
||||
model: ImagesModel<ImagesApi>,
|
||||
context: ImagesContext,
|
||||
options?: ImagesOptions,
|
||||
): Promise<AssistantImages>;
|
||||
}
|
||||
|
||||
export interface MutableImagesModels extends ImagesModels {
|
||||
/** Upsert/replace by provider.id. Provider ids are unique. */
|
||||
setProvider(provider: ImagesProvider): void;
|
||||
deleteProvider(id: string): void;
|
||||
clearProviders(): void;
|
||||
}
|
||||
|
||||
class ImagesModelsImpl implements MutableImagesModels {
|
||||
private providers = new Map<string, ImagesProvider>();
|
||||
private credentials: CredentialStore;
|
||||
private authContext: AuthContext;
|
||||
|
||||
constructor(options?: CreateModelsOptions) {
|
||||
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
|
||||
this.authContext = options?.authContext ?? defaultAuthContext();
|
||||
}
|
||||
|
||||
setProvider(provider: ImagesProvider): void {
|
||||
this.providers.set(provider.id, provider);
|
||||
}
|
||||
|
||||
deleteProvider(id: string): void {
|
||||
this.providers.delete(id);
|
||||
}
|
||||
|
||||
clearProviders(): void {
|
||||
this.providers.clear();
|
||||
}
|
||||
|
||||
getProviders(): readonly ImagesProvider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
getProvider(id: string): ImagesProvider | undefined {
|
||||
return this.providers.get(id);
|
||||
}
|
||||
|
||||
getModels(provider?: string): readonly ImagesModel<ImagesApi>[] {
|
||||
if (provider !== undefined) {
|
||||
const entry = this.providers.get(provider);
|
||||
if (!entry) return [];
|
||||
try {
|
||||
return entry.getModels();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const models: ImagesModel<ImagesApi>[] = [];
|
||||
for (const entry of this.providers.values()) {
|
||||
try {
|
||||
models.push(...entry.getModels());
|
||||
} catch {
|
||||
// Best-effort: ill-behaved providers yield no models.
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
getModel(provider: string, id: string): ImagesModel<ImagesApi> | undefined {
|
||||
return this.getModels(provider).find((model) => model.id === id);
|
||||
}
|
||||
|
||||
async refresh(provider?: string): Promise<void> {
|
||||
if (provider !== undefined) {
|
||||
const entry = this.providers.get(provider);
|
||||
if (!entry?.refreshModels) return;
|
||||
try {
|
||||
await entry.refreshModels();
|
||||
} catch (error) {
|
||||
if (error instanceof ModelsError) throw error;
|
||||
throw new ModelsError("model_source", `Model refresh failed for ${provider}`, { cause: error });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cannot reject: the async mapper turns even sync throws from ill-behaved
|
||||
// providers into rejections, and allSettled captures all of them.
|
||||
await Promise.allSettled(Array.from(this.providers.values(), async (entry) => entry.refreshModels?.()));
|
||||
}
|
||||
|
||||
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
getAuth(model: ImagesModel<ImagesApi>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
async getAuth(
|
||||
providerOrModel: string | ImagesModel<ImagesApi>,
|
||||
overrides?: AuthResolutionOverrides,
|
||||
): Promise<AuthResult | undefined> {
|
||||
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
|
||||
const provider = this.providers.get(providerId);
|
||||
if (!provider) return undefined;
|
||||
return resolveProviderAuth(provider, this.credentials, this.authContext, overrides);
|
||||
}
|
||||
|
||||
async generateImages(
|
||||
model: ImagesModel<ImagesApi>,
|
||||
context: ImagesContext,
|
||||
options?: ImagesOptions,
|
||||
): Promise<AssistantImages> {
|
||||
try {
|
||||
const provider = this.providers.get(model.provider);
|
||||
if (!provider) {
|
||||
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
|
||||
}
|
||||
|
||||
const resolution = await this.getAuth(model, {
|
||||
apiKey: options?.apiKey,
|
||||
env: options?.env,
|
||||
signal: options?.signal,
|
||||
});
|
||||
const auth = resolution?.auth;
|
||||
if (!auth) {
|
||||
return provider.generateImages(model, context, options);
|
||||
}
|
||||
|
||||
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
||||
|
||||
// Explicit request options win per-field; headers/env merge per key.
|
||||
const apiKey = options?.apiKey ?? auth.apiKey;
|
||||
const headers = auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined;
|
||||
const env =
|
||||
resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
|
||||
|
||||
return await provider.generateImages(requestModel, context, { ...options, apiKey, headers, env });
|
||||
} catch (error) {
|
||||
return {
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
output: [],
|
||||
stopReason: "error",
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createImagesModels(options?: CreateModelsOptions): MutableImagesModels {
|
||||
return new ImagesModelsImpl(options);
|
||||
}
|
||||
|
||||
export interface CreateImagesProviderOptions {
|
||||
id: string;
|
||||
/** Display name. Default: `id`. */
|
||||
name?: string;
|
||||
/** Required — every provider has auth semantics, even ambient/keyless ones. */
|
||||
auth: ProviderAuth;
|
||||
/** Initial model list (empty for purely dynamic providers). */
|
||||
models: readonly ImagesModel<ImagesApi>[];
|
||||
/**
|
||||
* Dynamic providers: fetch the current list. Stored on success; concurrent
|
||||
* calls share one in-flight fetch. May reject: the stored list then stays
|
||||
* at its last-known state, the rejection propagates to the caller of
|
||||
* `refreshModels()` (wrapped as ModelsError "model_source" by
|
||||
* `ImagesModels.refresh(provider)`), and a later call retries.
|
||||
*/
|
||||
refreshModels?: () => Promise<readonly ImagesModel<ImagesApi>[]>;
|
||||
api: ProviderImages;
|
||||
}
|
||||
|
||||
/** Builds an image-generation provider from parts. */
|
||||
export function createImagesProvider(input: CreateImagesProviderOptions): ImagesProvider {
|
||||
let models = input.models;
|
||||
let inflightRefresh: Promise<void> | undefined;
|
||||
const refreshModels = input.refreshModels;
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name ?? input.id,
|
||||
auth: input.auth,
|
||||
getModels: () => models,
|
||||
refreshModels: refreshModels
|
||||
? () => {
|
||||
inflightRefresh ??= (async () => {
|
||||
try {
|
||||
models = await refreshModels();
|
||||
} finally {
|
||||
inflightRefresh = undefined;
|
||||
}
|
||||
})();
|
||||
return inflightRefresh;
|
||||
}
|
||||
: undefined,
|
||||
generateImages: (model, context, options) => input.api.generateImages(model, context, options),
|
||||
};
|
||||
}
|
||||
21
packages/ai/src/images.ts
Normal file
21
packages/ai/src/images.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import "./providers/images/register-builtins.ts";
|
||||
|
||||
import { getImagesApiProvider } from "./images-api-registry.ts";
|
||||
import type { AssistantImages, ImagesApi, ImagesContext, ImagesModel, ProviderImagesOptions } from "./types.ts";
|
||||
|
||||
function resolveImagesApiProvider(api: ImagesApi) {
|
||||
const provider = getImagesApiProvider(api);
|
||||
if (!provider) {
|
||||
throw new Error(`No API provider registered for api: ${api}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function generateImages<TApi extends ImagesApi>(
|
||||
model: ImagesModel<TApi>,
|
||||
context: ImagesContext,
|
||||
options?: ProviderImagesOptions,
|
||||
): Promise<AssistantImages> {
|
||||
const provider = resolveImagesApiProvider(model.api);
|
||||
return provider.generateImages(model, context, options);
|
||||
}
|
||||
48
packages/ai/src/index.ts
Normal file
48
packages/ai/src/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type { Static, TSchema } from "typebox";
|
||||
export { Type } from "typebox";
|
||||
|
||||
// Core only, side-effect free: no generated catalogs, no provider factories,
|
||||
// no api-registry, no OAuth implementations, no compat. Provider factories
|
||||
// live under "@earendil-works/pi-ai/providers/*", API implementations under
|
||||
// "@earendil-works/pi-ai/api/*", the old global API under
|
||||
// "@earendil-works/pi-ai/compat".
|
||||
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./api/anthropic-messages.ts";
|
||||
export type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";
|
||||
export type { BedrockOptions, BedrockThinkingDisplay } from "./api/bedrock-converse-stream.ts";
|
||||
export type { GoogleOptions } from "./api/google-generative-ai.ts";
|
||||
export type { GoogleApiThinkingLevel, ResolvedGoogleThinkingLevel } from "./api/google-shared.ts";
|
||||
export type { GoogleVertexOptions } from "./api/google-vertex.ts";
|
||||
export * from "./api/lazy.ts";
|
||||
export type { MistralOptions } from "./api/mistral-conversations.ts";
|
||||
export type { OpenAICodexResponsesOptions, OpenAICodexWebSocketDebugStats } from "./api/openai-codex-responses.ts";
|
||||
export type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
|
||||
export type { OpenAIResponsesOptions } from "./api/openai-responses.ts";
|
||||
export type { PiMessagesEvent, PiMessagesOptions, PiMessagesRewriteImpact } from "./api/pi-messages.ts";
|
||||
export * from "./auth/context.ts";
|
||||
export * from "./auth/credential-store.ts";
|
||||
export * from "./auth/helpers.ts";
|
||||
export * from "./auth/types.ts";
|
||||
export type {
|
||||
OAuthAuthInfo,
|
||||
OAuthDeviceCodeInfo,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthSelectOption,
|
||||
OAuthSelectPrompt,
|
||||
} from "./compat/extension-oauth-types.ts";
|
||||
export * from "./images-models.ts";
|
||||
export * from "./models.ts";
|
||||
export * from "./models-store.ts";
|
||||
export * from "./providers/faux.ts";
|
||||
export * from "./session-resources.ts";
|
||||
export * from "./types.ts";
|
||||
export * from "./utils/assistant-message-frame.ts";
|
||||
export * from "./utils/diagnostics.ts";
|
||||
export * from "./utils/event-stream.ts";
|
||||
export * from "./utils/json-parse.ts";
|
||||
export * from "./utils/overflow.ts";
|
||||
export * from "./utils/retry.ts";
|
||||
export { contentText } from "./utils/text.ts";
|
||||
export * from "./utils/typebox-helpers.ts";
|
||||
export { uuidv7 } from "./utils/uuid.ts";
|
||||
export * from "./utils/validation.ts";
|
||||
108
packages/ai/src/legacy-api-aliases.ts
Normal file
108
packages/ai/src/legacy-api-aliases.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { anthropicMessagesApi } from "./api/anthropic-messages.lazy.ts";
|
||||
import type { AnthropicOptions } from "./api/anthropic-messages.ts";
|
||||
import { azureOpenAIResponsesApi } from "./api/azure-openai-responses.lazy.ts";
|
||||
import type { AzureOpenAIResponsesOptions } from "./api/azure-openai-responses.ts";
|
||||
import { googleGenerativeAIApi } from "./api/google-generative-ai.lazy.ts";
|
||||
import type { GoogleOptions } from "./api/google-generative-ai.ts";
|
||||
import { googleVertexApi } from "./api/google-vertex.lazy.ts";
|
||||
import type { GoogleVertexOptions } from "./api/google-vertex.ts";
|
||||
import { mistralConversationsApi } from "./api/mistral-conversations.lazy.ts";
|
||||
import type { MistralOptions } from "./api/mistral-conversations.ts";
|
||||
import { openAICodexResponsesApi } from "./api/openai-codex-responses.lazy.ts";
|
||||
import type { OpenAICodexResponsesOptions } from "./api/openai-codex-responses.ts";
|
||||
import { openAICompletionsApi } from "./api/openai-completions.lazy.ts";
|
||||
import type { OpenAICompletionsOptions } from "./api/openai-completions.ts";
|
||||
import { openAIResponsesApi } from "./api/openai-responses.lazy.ts";
|
||||
import type { OpenAIResponsesOptions } from "./api/openai-responses.ts";
|
||||
import type { SimpleStreamOptions, StreamFunction } from "./types.ts";
|
||||
|
||||
const anthropicMessagesStreams = anthropicMessagesApi();
|
||||
const azureOpenAIResponsesStreams = azureOpenAIResponsesApi();
|
||||
const googleGenerativeAIStreams = googleGenerativeAIApi();
|
||||
const googleVertexStreams = googleVertexApi();
|
||||
const mistralConversationsStreams = mistralConversationsApi();
|
||||
const openAICodexResponsesStreams = openAICodexResponsesApi();
|
||||
const openAICompletionsStreams = openAICompletionsApi();
|
||||
const openAIResponsesStreams = openAIResponsesApi();
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/anthropic-messages` or `anthropicMessagesApi().stream`. */
|
||||
export const streamAnthropic = anthropicMessagesStreams.stream as StreamFunction<
|
||||
"anthropic-messages",
|
||||
AnthropicOptions
|
||||
>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/anthropic-messages` or `anthropicMessagesApi().streamSimple`. */
|
||||
export const streamSimpleAnthropic = anthropicMessagesStreams.streamSimple as StreamFunction<
|
||||
"anthropic-messages",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/azure-openai-responses` or `azureOpenAIResponsesApi().stream`. */
|
||||
export const streamAzureOpenAIResponses = azureOpenAIResponsesStreams.stream as StreamFunction<
|
||||
"azure-openai-responses",
|
||||
AzureOpenAIResponsesOptions
|
||||
>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/azure-openai-responses` or `azureOpenAIResponsesApi().streamSimple`. */
|
||||
export const streamSimpleAzureOpenAIResponses = azureOpenAIResponsesStreams.streamSimple as StreamFunction<
|
||||
"azure-openai-responses",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/google-generative-ai` or `googleGenerativeAIApi().stream`. */
|
||||
export const streamGoogle = googleGenerativeAIStreams.stream as StreamFunction<"google-generative-ai", GoogleOptions>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/google-generative-ai` or `googleGenerativeAIApi().streamSimple`. */
|
||||
export const streamSimpleGoogle = googleGenerativeAIStreams.streamSimple as StreamFunction<
|
||||
"google-generative-ai",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/google-vertex` or `googleVertexApi().stream`. */
|
||||
export const streamGoogleVertex = googleVertexStreams.stream as StreamFunction<"google-vertex", GoogleVertexOptions>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/google-vertex` or `googleVertexApi().streamSimple`. */
|
||||
export const streamSimpleGoogleVertex = googleVertexStreams.streamSimple as StreamFunction<
|
||||
"google-vertex",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/mistral-conversations` or `mistralConversationsApi().stream`. */
|
||||
export const streamMistral = mistralConversationsStreams.stream as StreamFunction<
|
||||
"mistral-conversations",
|
||||
MistralOptions
|
||||
>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/mistral-conversations` or `mistralConversationsApi().streamSimple`. */
|
||||
export const streamSimpleMistral = mistralConversationsStreams.streamSimple as StreamFunction<
|
||||
"mistral-conversations",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/openai-codex-responses` or `openAICodexResponsesApi().stream`. */
|
||||
export const streamOpenAICodexResponses = openAICodexResponsesStreams.stream as StreamFunction<
|
||||
"openai-codex-responses",
|
||||
OpenAICodexResponsesOptions
|
||||
>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/openai-codex-responses` or `openAICodexResponsesApi().streamSimple`. */
|
||||
export const streamSimpleOpenAICodexResponses = openAICodexResponsesStreams.streamSimple as StreamFunction<
|
||||
"openai-codex-responses",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/openai-completions` or `openAICompletionsApi().stream`. */
|
||||
export const streamOpenAICompletions = openAICompletionsStreams.stream as StreamFunction<
|
||||
"openai-completions",
|
||||
OpenAICompletionsOptions
|
||||
>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/openai-completions` or `openAICompletionsApi().streamSimple`. */
|
||||
export const streamSimpleOpenAICompletions = openAICompletionsStreams.streamSimple as StreamFunction<
|
||||
"openai-completions",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
|
||||
/** @deprecated Use `stream` from `@earendil-works/pi-ai/api/openai-responses` or `openAIResponsesApi().stream`. */
|
||||
export const streamOpenAIResponses = openAIResponsesStreams.stream as StreamFunction<
|
||||
"openai-responses",
|
||||
OpenAIResponsesOptions
|
||||
>;
|
||||
/** @deprecated Use `streamSimple` from `@earendil-works/pi-ai/api/openai-responses` or `openAIResponsesApi().streamSimple`. */
|
||||
export const streamSimpleOpenAIResponses = openAIResponsesStreams.streamSimple as StreamFunction<
|
||||
"openai-responses",
|
||||
SimpleStreamOptions
|
||||
>;
|
||||
27
packages/ai/src/model-catalog.ts
Normal file
27
packages/ai/src/model-catalog.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Api, Model, ProviderId } from "./types.ts";
|
||||
|
||||
export type ModelGroups = Record<string, Record<string, object>>;
|
||||
|
||||
type ModelId<TGroups extends ModelGroups> = {
|
||||
[TApi in keyof TGroups]: keyof TGroups[TApi];
|
||||
}[keyof TGroups] &
|
||||
string;
|
||||
|
||||
type ModelApi<TGroups extends ModelGroups, TModelId extends ModelId<TGroups>> = {
|
||||
[TApi in keyof TGroups]: TModelId extends keyof TGroups[TApi] ? TApi : never;
|
||||
}[keyof TGroups] &
|
||||
Api;
|
||||
|
||||
export type ModelCatalog<TGroups extends ModelGroups, TProvider extends ProviderId> = {
|
||||
[TModelId in ModelId<TGroups>]: Model<ModelApi<TGroups, TModelId>> & {
|
||||
id: TModelId;
|
||||
provider: TProvider;
|
||||
};
|
||||
};
|
||||
|
||||
export function flattenModelCatalog<const TProvider extends ProviderId, const TGroups extends ModelGroups>(
|
||||
_provider: TProvider,
|
||||
groups: TGroups,
|
||||
): ModelCatalog<TGroups, TProvider> {
|
||||
return Object.assign({}, ...Object.values(groups)) as ModelCatalog<TGroups, TProvider>;
|
||||
}
|
||||
45
packages/ai/src/models-store.ts
Normal file
45
packages/ai/src/models-store.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Api, Model } from "./types.ts";
|
||||
|
||||
export interface ModelsStoreEntry {
|
||||
models: readonly Model<Api>[];
|
||||
/** Unix timestamp from the remote catalog's Last-Modified header. */
|
||||
lastModified?: number;
|
||||
/** Unix timestamp of the last completed remote check. */
|
||||
checkedAt?: number;
|
||||
/**
|
||||
* Opaque validator from the remote catalog's ETag header, stored verbatim
|
||||
* (quotes included) and echoed back as If-None-Match.
|
||||
*/
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export interface ModelsStoreOperationOptions {
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Persistent model catalogs keyed by provider ID. */
|
||||
export interface ModelsStore {
|
||||
read(providerId: string, options?: ModelsStoreOperationOptions): Promise<ModelsStoreEntry | undefined>;
|
||||
write(providerId: string, entry: ModelsStoreEntry, options?: ModelsStoreOperationOptions): Promise<void>;
|
||||
delete(providerId: string, options?: ModelsStoreOperationOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export class InMemoryModelsStore implements ModelsStore {
|
||||
private readonly entries = new Map<string, ModelsStoreEntry>();
|
||||
|
||||
async read(providerId: string, options?: ModelsStoreOperationOptions): Promise<ModelsStoreEntry | undefined> {
|
||||
options?.signal?.throwIfAborted();
|
||||
const entry = this.entries.get(providerId);
|
||||
return entry ? structuredClone(entry) : undefined;
|
||||
}
|
||||
|
||||
async write(providerId: string, entry: ModelsStoreEntry, options?: ModelsStoreOperationOptions): Promise<void> {
|
||||
options?.signal?.throwIfAborted();
|
||||
this.entries.set(providerId, structuredClone(entry));
|
||||
}
|
||||
|
||||
async delete(providerId: string, options?: ModelsStoreOperationOptions): Promise<void> {
|
||||
options?.signal?.throwIfAborted();
|
||||
this.entries.delete(providerId);
|
||||
}
|
||||
}
|
||||
124
packages/ai/src/models.generated.ts
Normal file
124
packages/ai/src/models.generated.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import { AMAZON_BEDROCK_MODELS } from "./providers/amazon-bedrock.models.ts";
|
||||
import { ANT_LING_MODELS } from "./providers/ant-ling.models.ts";
|
||||
import { ANTHROPIC_MODELS } from "./providers/anthropic.models.ts";
|
||||
import { AZURE_OPENAI_RESPONSES_MODELS } from "./providers/azure-openai-responses.models.ts";
|
||||
import { BASETEN_MODELS } from "./providers/baseten.models.ts";
|
||||
import { CEREBRAS_MODELS } from "./providers/cerebras.models.ts";
|
||||
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./providers/cloudflare-ai-gateway.models.ts";
|
||||
import { CLOUDFLARE_WORKERS_AI_MODELS } from "./providers/cloudflare-workers-ai.models.ts";
|
||||
import { DEEPSEEK_MODELS } from "./providers/deepseek.models.ts";
|
||||
import { FIREWORKS_MODELS } from "./providers/fireworks.models.ts";
|
||||
import { GITHUB_COPILOT_MODELS } from "./providers/github-copilot.models.ts";
|
||||
import { GOOGLE_MODELS } from "./providers/google.models.ts";
|
||||
import { GOOGLE_VERTEX_MODELS } from "./providers/google-vertex.models.ts";
|
||||
import { GROQ_MODELS } from "./providers/groq.models.ts";
|
||||
import { HUGGINGFACE_MODELS } from "./providers/huggingface.models.ts";
|
||||
import { KIMI_CODING_MODELS } from "./providers/kimi-coding.models.ts";
|
||||
import { MINIMAX_MODELS } from "./providers/minimax.models.ts";
|
||||
import { MINIMAX_CN_MODELS } from "./providers/minimax-cn.models.ts";
|
||||
import { MISTRAL_MODELS } from "./providers/mistral.models.ts";
|
||||
import { MOONSHOTAI_MODELS } from "./providers/moonshotai.models.ts";
|
||||
import { MOONSHOTAI_CN_MODELS } from "./providers/moonshotai-cn.models.ts";
|
||||
import { NVIDIA_MODELS } from "./providers/nvidia.models.ts";
|
||||
import { OPENAI_MODELS } from "./providers/openai.models.ts";
|
||||
import { OPENAI_CODEX_MODELS } from "./providers/openai-codex.models.ts";
|
||||
import { OPENCODE_MODELS } from "./providers/opencode.models.ts";
|
||||
import { OPENCODE_GO_MODELS } from "./providers/opencode-go.models.ts";
|
||||
import { OPENROUTER_MODELS } from "./providers/openrouter.models.ts";
|
||||
import { QWEN_TOKEN_PLAN_MODELS } from "./providers/qwen-token-plan.models.ts";
|
||||
import { QWEN_TOKEN_PLAN_CN_MODELS } from "./providers/qwen-token-plan-cn.models.ts";
|
||||
import { QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS } from "./providers/qwen-token-plan-individual.models.ts";
|
||||
import { TOGETHER_MODELS } from "./providers/together.models.ts";
|
||||
import { VERCEL_AI_GATEWAY_MODELS } from "./providers/vercel-ai-gateway.models.ts";
|
||||
import { XAI_MODELS } from "./providers/xai.models.ts";
|
||||
import { XIAOMI_MODELS } from "./providers/xiaomi.models.ts";
|
||||
import { XIAOMI_TOKEN_PLAN_AMS_MODELS } from "./providers/xiaomi-token-plan-ams.models.ts";
|
||||
import { XIAOMI_TOKEN_PLAN_CN_MODELS } from "./providers/xiaomi-token-plan-cn.models.ts";
|
||||
import { XIAOMI_TOKEN_PLAN_SGP_MODELS } from "./providers/xiaomi-token-plan-sgp.models.ts";
|
||||
import { ZAI_MODELS } from "./providers/zai.models.ts";
|
||||
import { ZAI_CODING_CN_MODELS } from "./providers/zai-coding-cn.models.ts";
|
||||
|
||||
export const MODELS: {
|
||||
readonly "amazon-bedrock": typeof AMAZON_BEDROCK_MODELS;
|
||||
readonly "ant-ling": typeof ANT_LING_MODELS;
|
||||
readonly "anthropic": typeof ANTHROPIC_MODELS;
|
||||
readonly "azure-openai-responses": typeof AZURE_OPENAI_RESPONSES_MODELS;
|
||||
readonly "baseten": typeof BASETEN_MODELS;
|
||||
readonly "cerebras": typeof CEREBRAS_MODELS;
|
||||
readonly "cloudflare-ai-gateway": typeof CLOUDFLARE_AI_GATEWAY_MODELS;
|
||||
readonly "cloudflare-workers-ai": typeof CLOUDFLARE_WORKERS_AI_MODELS;
|
||||
readonly "deepseek": typeof DEEPSEEK_MODELS;
|
||||
readonly "fireworks": typeof FIREWORKS_MODELS;
|
||||
readonly "github-copilot": typeof GITHUB_COPILOT_MODELS;
|
||||
readonly "google": typeof GOOGLE_MODELS;
|
||||
readonly "google-vertex": typeof GOOGLE_VERTEX_MODELS;
|
||||
readonly "groq": typeof GROQ_MODELS;
|
||||
readonly "huggingface": typeof HUGGINGFACE_MODELS;
|
||||
readonly "kimi-coding": typeof KIMI_CODING_MODELS;
|
||||
readonly "minimax": typeof MINIMAX_MODELS;
|
||||
readonly "minimax-cn": typeof MINIMAX_CN_MODELS;
|
||||
readonly "mistral": typeof MISTRAL_MODELS;
|
||||
readonly "moonshotai": typeof MOONSHOTAI_MODELS;
|
||||
readonly "moonshotai-cn": typeof MOONSHOTAI_CN_MODELS;
|
||||
readonly "nvidia": typeof NVIDIA_MODELS;
|
||||
readonly "openai": typeof OPENAI_MODELS;
|
||||
readonly "openai-codex": typeof OPENAI_CODEX_MODELS;
|
||||
readonly "opencode": typeof OPENCODE_MODELS;
|
||||
readonly "opencode-go": typeof OPENCODE_GO_MODELS;
|
||||
readonly "openrouter": typeof OPENROUTER_MODELS;
|
||||
readonly "qwen-token-plan": typeof QWEN_TOKEN_PLAN_MODELS;
|
||||
readonly "qwen-token-plan-cn": typeof QWEN_TOKEN_PLAN_CN_MODELS;
|
||||
readonly "qwen-token-plan-individual": typeof QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS;
|
||||
readonly "together": typeof TOGETHER_MODELS;
|
||||
readonly "vercel-ai-gateway": typeof VERCEL_AI_GATEWAY_MODELS;
|
||||
readonly "xai": typeof XAI_MODELS;
|
||||
readonly "xiaomi": typeof XIAOMI_MODELS;
|
||||
readonly "xiaomi-token-plan-ams": typeof XIAOMI_TOKEN_PLAN_AMS_MODELS;
|
||||
readonly "xiaomi-token-plan-cn": typeof XIAOMI_TOKEN_PLAN_CN_MODELS;
|
||||
readonly "xiaomi-token-plan-sgp": typeof XIAOMI_TOKEN_PLAN_SGP_MODELS;
|
||||
readonly "zai": typeof ZAI_MODELS;
|
||||
readonly "zai-coding-cn": typeof ZAI_CODING_CN_MODELS;
|
||||
} = {
|
||||
"amazon-bedrock": AMAZON_BEDROCK_MODELS,
|
||||
"ant-ling": ANT_LING_MODELS,
|
||||
"anthropic": ANTHROPIC_MODELS,
|
||||
"azure-openai-responses": AZURE_OPENAI_RESPONSES_MODELS,
|
||||
"baseten": BASETEN_MODELS,
|
||||
"cerebras": CEREBRAS_MODELS,
|
||||
"cloudflare-ai-gateway": CLOUDFLARE_AI_GATEWAY_MODELS,
|
||||
"cloudflare-workers-ai": CLOUDFLARE_WORKERS_AI_MODELS,
|
||||
"deepseek": DEEPSEEK_MODELS,
|
||||
"fireworks": FIREWORKS_MODELS,
|
||||
"github-copilot": GITHUB_COPILOT_MODELS,
|
||||
"google": GOOGLE_MODELS,
|
||||
"google-vertex": GOOGLE_VERTEX_MODELS,
|
||||
"groq": GROQ_MODELS,
|
||||
"huggingface": HUGGINGFACE_MODELS,
|
||||
"kimi-coding": KIMI_CODING_MODELS,
|
||||
"minimax": MINIMAX_MODELS,
|
||||
"minimax-cn": MINIMAX_CN_MODELS,
|
||||
"mistral": MISTRAL_MODELS,
|
||||
"moonshotai": MOONSHOTAI_MODELS,
|
||||
"moonshotai-cn": MOONSHOTAI_CN_MODELS,
|
||||
"nvidia": NVIDIA_MODELS,
|
||||
"openai": OPENAI_MODELS,
|
||||
"openai-codex": OPENAI_CODEX_MODELS,
|
||||
"opencode": OPENCODE_MODELS,
|
||||
"opencode-go": OPENCODE_GO_MODELS,
|
||||
"openrouter": OPENROUTER_MODELS,
|
||||
"qwen-token-plan": QWEN_TOKEN_PLAN_MODELS,
|
||||
"qwen-token-plan-cn": QWEN_TOKEN_PLAN_CN_MODELS,
|
||||
"qwen-token-plan-individual": QWEN_TOKEN_PLAN_INDIVIDUAL_MODELS,
|
||||
"together": TOGETHER_MODELS,
|
||||
"vercel-ai-gateway": VERCEL_AI_GATEWAY_MODELS,
|
||||
"xai": XAI_MODELS,
|
||||
"xiaomi": XIAOMI_MODELS,
|
||||
"xiaomi-token-plan-ams": XIAOMI_TOKEN_PLAN_AMS_MODELS,
|
||||
"xiaomi-token-plan-cn": XIAOMI_TOKEN_PLAN_CN_MODELS,
|
||||
"xiaomi-token-plan-sgp": XIAOMI_TOKEN_PLAN_SGP_MODELS,
|
||||
"zai": ZAI_MODELS,
|
||||
"zai-coding-cn": ZAI_CODING_CN_MODELS,
|
||||
};
|
||||
957
packages/ai/src/models.ts
Normal file
957
packages/ai/src/models.ts
Normal file
@@ -0,0 +1,957 @@
|
||||
import { lazyStream } from "./api/lazy.ts";
|
||||
import { defaultProviderAuthContext as defaultAuthContext } from "./auth/context.ts";
|
||||
import { InMemoryCredentialStore } from "./auth/credential-store.ts";
|
||||
import { type AuthResolutionOverrides, ModelsError, resolveProviderAuth } from "./auth/resolve.ts";
|
||||
import type {
|
||||
AuthCheck,
|
||||
AuthContext,
|
||||
AuthInteraction,
|
||||
AuthOperationOptions,
|
||||
AuthResult,
|
||||
AuthType,
|
||||
Credential,
|
||||
CredentialStore,
|
||||
ProviderAuth,
|
||||
} from "./auth/types.ts";
|
||||
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";
|
||||
import type {
|
||||
Api,
|
||||
ApiStreamOptions,
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
Context,
|
||||
DeferredCancelOptions,
|
||||
DeferredFetchOptions,
|
||||
DeferredHandle,
|
||||
Model,
|
||||
ModelCostRates,
|
||||
ModelThinkingLevel,
|
||||
ProviderHeaders,
|
||||
ProviderRequestOptions,
|
||||
ProviderStreams,
|
||||
SimpleStreamOptions,
|
||||
Usage,
|
||||
} from "./types.ts";
|
||||
import { operationSignal, raceWithAbortSignal } from "./utils/abort.ts";
|
||||
|
||||
export { ModelsError, type ModelsErrorCode } from "./auth/resolve.ts";
|
||||
|
||||
export interface ModelsPublication {
|
||||
/** Provider-selected persisted catalog. Omit to leave storage unchanged; null deletes it. */
|
||||
persist?: ModelsStoreEntry | null;
|
||||
/** Optional synchronous update of provider-private in-memory catalog state. */
|
||||
update?: () => void;
|
||||
}
|
||||
|
||||
export interface RefreshModelsContext {
|
||||
/** Effective configured credential. OAuth credentials are refreshed before network access. */
|
||||
credential?: Credential;
|
||||
/** Immutable provider-scoped catalog snapshot captured before this refresh phase. */
|
||||
stored?: Readonly<ModelsStoreEntry>;
|
||||
/**
|
||||
* Generation-checked publication. Persistence policy remains provider-owned;
|
||||
* the update runs synchronously only after the selected persistence mutation.
|
||||
*/
|
||||
publish(publication: ModelsPublication): Promise<boolean>;
|
||||
/** False during offline/cache-only initialization. */
|
||||
allowNetwork: boolean;
|
||||
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */
|
||||
force?: boolean;
|
||||
/** Always present, including when the public refresh caller omits its optional signal. */
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModelsRefreshOptions {
|
||||
allowNetwork?: boolean;
|
||||
/** Restrict refresh to these provider IDs. Unknown and static providers are ignored. */
|
||||
providers?: readonly string[];
|
||||
/** Bypass provider freshness checks and fetch immediately when network access is allowed. */
|
||||
force?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ModelsRefreshResult {
|
||||
aborted: boolean;
|
||||
errors: ReadonlyMap<string, Error>;
|
||||
}
|
||||
|
||||
export interface ModelsRequestTransforms {
|
||||
/** Transform fully assembled model/auth/request headers before provider dispatch. */
|
||||
transformHeaders?: (headers: ProviderHeaders) => ProviderHeaders | Promise<ProviderHeaders>;
|
||||
}
|
||||
|
||||
export type ModelsApiStreamOptions<TApi extends Api> = ApiStreamOptions<TApi> & ModelsRequestTransforms;
|
||||
export type ModelsSimpleStreamOptions = SimpleStreamOptions & ModelsRequestTransforms;
|
||||
export type ModelsDeferredFetchOptions = DeferredFetchOptions & ModelsRequestTransforms;
|
||||
export type ModelsDeferredCancelOptions = DeferredCancelOptions & ModelsRequestTransforms;
|
||||
|
||||
/**
|
||||
* A provider is the concrete runtime unit. It owns id/name/base metadata,
|
||||
* auth methods, model listing, and stream behavior.
|
||||
*
|
||||
* `TApi` lets concrete provider factories declare which APIs their models
|
||||
* use (e.g. `openaiProvider(): Provider<"openai-responses" | "openai-completions">`),
|
||||
* giving typed model lists to direct factory users. Inside a `Models`
|
||||
* collection providers are held as `Provider<Api>`.
|
||||
*/
|
||||
export interface Provider<TApi extends Api = Api> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
|
||||
readonly baseUrl?: string;
|
||||
readonly headers?: ProviderHeaders;
|
||||
|
||||
/**
|
||||
* Required: at least one of `apiKey`/`oauth`. Every provider has auth
|
||||
* semantics — even providers with only ambient credentials (env vars, AWS
|
||||
* profiles, ADC files) and keyless local servers provide `apiKey` auth
|
||||
* whose `resolve()` reports whether the provider is configured.
|
||||
* `Models.getAuth()` returns undefined when the provider is unconfigured.
|
||||
*/
|
||||
readonly auth: ProviderAuth;
|
||||
|
||||
/**
|
||||
* Current known models, sync. Static providers return their catalog;
|
||||
* dynamic providers return the list as of the last `refreshModels()`
|
||||
* (empty before the first). Must not throw; `Models` treats a throwing
|
||||
* implementation as having no models.
|
||||
*/
|
||||
getModels(): readonly Model<TApi>[];
|
||||
|
||||
/**
|
||||
* Dynamic providers only: restore `context.stored` and optionally fetch a newer list using
|
||||
* the effective credential. Implementations retain their previous list on failure, publish
|
||||
* persistence and synchronous state changes through `context.publish()`, and honor the
|
||||
* shared abort signal for blocking work.
|
||||
*/
|
||||
refreshModels?(context: RefreshModelsContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional provider policy for credential-specific model availability.
|
||||
* `getModels()` remains the complete synchronous catalog; `Models.getAvailable()`
|
||||
* applies this filter after confirming that provider auth is configured.
|
||||
*/
|
||||
filterModels?(models: readonly Model<TApi>[], credential: Credential | undefined): readonly Model<TApi>[];
|
||||
|
||||
stream<T extends TApi>(
|
||||
model: Model<T>,
|
||||
context: Context,
|
||||
options?: ApiStreamOptions<T>,
|
||||
): AssistantMessageEventStream;
|
||||
|
||||
streamSimple(model: Model<TApi>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream;
|
||||
fetchDeferred?(
|
||||
model: Model<TApi>,
|
||||
handle: DeferredHandle,
|
||||
options?: DeferredFetchOptions,
|
||||
): AssistantMessageEventStream;
|
||||
cancelDeferred?(model: Model<TApi>, handle: DeferredHandle, options?: DeferredCancelOptions): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime collection of providers plus auth application and stream
|
||||
* convenience. Providers own stream behavior; `Models` resolves auth and
|
||||
* delegates each request to the provider that owns the model.
|
||||
*/
|
||||
export interface Models {
|
||||
getProviders(): readonly Provider[];
|
||||
getProvider(id: string): Provider | undefined;
|
||||
|
||||
/**
|
||||
* Sync read of last-known models from one provider or all providers.
|
||||
* Best-effort: a provider whose `getModels()` throws yields no models.
|
||||
*/
|
||||
getModels(provider?: string): readonly Model<Api>[];
|
||||
|
||||
/**
|
||||
* Sync runtime model lookup against last-known lists. Dynamic model lists
|
||||
* are typed as `Model<Api>`; narrow with the `hasApi()` type guard.
|
||||
*/
|
||||
getModel(provider: string, id: string): Model<Api> | undefined;
|
||||
|
||||
/**
|
||||
* Refresh selected configured dynamic providers concurrently (all when `providers` is omitted).
|
||||
* Provider errors and cancellation are returned without rejecting; static, unknown, and
|
||||
* unconfigured providers are skipped.
|
||||
*/
|
||||
refresh(options?: ModelsRefreshOptions): Promise<ModelsRefreshResult>;
|
||||
|
||||
/** Check whether a provider has complete auth configuration without refreshing OAuth. */
|
||||
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined>;
|
||||
|
||||
/** Return models whose providers have complete auth configuration. */
|
||||
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]>;
|
||||
|
||||
/**
|
||||
* Resolve provider-scoped auth by provider id, or provider auth plus static
|
||||
* model headers when passed a model. Includes a source label for status UI.
|
||||
* Resolves `undefined` when the provider is unknown or unconfigured.
|
||||
* Rejects with `ModelsError`: code "oauth" when a token refresh fails (the
|
||||
* stored credential is preserved for retry; re-login fixes it), code "auth"
|
||||
* when api-key resolution or the credential store fails. Request paths
|
||||
* surface rejections as stream errors.
|
||||
*/
|
||||
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
|
||||
/** Run a provider-owned login flow and persist its returned credential. */
|
||||
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
|
||||
|
||||
/** Remove the stored credential for a provider. */
|
||||
logout(providerId: string, options?: AuthOperationOptions): Promise<void>;
|
||||
|
||||
stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ModelsApiStreamOptions<TApi>,
|
||||
): AssistantMessageEventStream;
|
||||
|
||||
complete<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ModelsApiStreamOptions<TApi>,
|
||||
): Promise<AssistantMessage>;
|
||||
|
||||
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream;
|
||||
completeSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): Promise<AssistantMessage>;
|
||||
streamDeferred(
|
||||
model: Model<Api>,
|
||||
handle: DeferredHandle,
|
||||
options?: ModelsDeferredFetchOptions,
|
||||
): AssistantMessageEventStream;
|
||||
fetchDeferred(
|
||||
model: Model<Api>,
|
||||
handle: DeferredHandle,
|
||||
options?: ModelsDeferredFetchOptions,
|
||||
): Promise<AssistantMessage>;
|
||||
cancelDeferred(model: Model<Api>, handle: DeferredHandle, options?: ModelsDeferredCancelOptions): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MutableModels extends Models {
|
||||
/** Upsert/replace by provider.id. Provider ids are unique. */
|
||||
setProvider(provider: Provider): void;
|
||||
deleteProvider(id: string): void;
|
||||
clearProviders(): void;
|
||||
}
|
||||
|
||||
export interface CreateModelsOptions {
|
||||
credentials?: CredentialStore;
|
||||
modelsStore?: ModelsStore;
|
||||
authContext?: AuthContext;
|
||||
}
|
||||
|
||||
function mergeHeaders(
|
||||
base: ProviderHeaders | undefined,
|
||||
override: ProviderHeaders | undefined,
|
||||
): ProviderHeaders | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
const merged = { ...base };
|
||||
for (const [name, value] of Object.entries(override ?? {})) {
|
||||
const lowerName = name.toLowerCase();
|
||||
for (const existingName of Object.keys(merged)) {
|
||||
if (existingName.toLowerCase() === lowerName) delete merged[existingName];
|
||||
}
|
||||
merged[name] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
class ModelsImpl implements MutableModels {
|
||||
private providers = new Map<string, Provider>();
|
||||
private credentials: CredentialStore;
|
||||
private modelsStore: ModelsStore;
|
||||
private authContext: AuthContext;
|
||||
private refreshGenerations = new Map<string, number>();
|
||||
private refreshControllers = new Map<string, AbortController>();
|
||||
private publicationChains = new Map<string, Promise<unknown>>();
|
||||
|
||||
constructor(options?: CreateModelsOptions) {
|
||||
this.credentials = options?.credentials ?? new InMemoryCredentialStore();
|
||||
this.modelsStore = options?.modelsStore ?? new InMemoryModelsStore();
|
||||
this.authContext = options?.authContext ?? defaultAuthContext();
|
||||
}
|
||||
|
||||
setProvider(provider: Provider): void {
|
||||
this.supersedeProviderRefresh(provider.id);
|
||||
this.providers.set(provider.id, provider);
|
||||
}
|
||||
|
||||
deleteProvider(id: string): void {
|
||||
this.supersedeProviderRefresh(id);
|
||||
this.providers.delete(id);
|
||||
}
|
||||
|
||||
clearProviders(): void {
|
||||
for (const id of new Set([...this.providers.keys(), ...this.refreshControllers.keys()])) {
|
||||
this.supersedeProviderRefresh(id);
|
||||
}
|
||||
this.providers.clear();
|
||||
}
|
||||
|
||||
getProviders(): readonly Provider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
getProvider(id: string): Provider | undefined {
|
||||
return this.providers.get(id);
|
||||
}
|
||||
|
||||
getModels(provider?: string): readonly Model<Api>[] {
|
||||
if (provider !== undefined) {
|
||||
const entry = this.providers.get(provider);
|
||||
if (!entry) return [];
|
||||
try {
|
||||
return entry.getModels();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const models: Model<Api>[] = [];
|
||||
for (const entry of this.providers.values()) {
|
||||
try {
|
||||
models.push(...entry.getModels());
|
||||
} catch {
|
||||
// Best-effort: ill-behaved providers yield no models.
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
getModel(provider: string, id: string): Model<Api> | undefined {
|
||||
return this.getModels(provider).find((model) => model.id === id);
|
||||
}
|
||||
|
||||
private supersedeProviderRefresh(providerId: string): number {
|
||||
const generation = (this.refreshGenerations.get(providerId) ?? 0) + 1;
|
||||
this.refreshGenerations.set(providerId, generation);
|
||||
const previous = this.refreshControllers.get(providerId);
|
||||
if (previous) {
|
||||
this.refreshControllers.delete(providerId);
|
||||
previous.abort();
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
private beginProviderRefresh(providerId: string): { generation: number; controller: AbortController } {
|
||||
const generation = this.supersedeProviderRefresh(providerId);
|
||||
const controller = new AbortController();
|
||||
this.refreshControllers.set(providerId, controller);
|
||||
return { generation, controller };
|
||||
}
|
||||
|
||||
private publishProviderModels(
|
||||
providerId: string,
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
publication: ModelsPublication,
|
||||
): Promise<boolean> {
|
||||
const previous = this.publicationChains.get(providerId) ?? Promise.resolve();
|
||||
const queued = (async () => {
|
||||
await previous.catch(() => {});
|
||||
if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) return false;
|
||||
|
||||
if (publication.persist === null) {
|
||||
await this.modelsStore.delete(providerId, { signal });
|
||||
} else if (publication.persist !== undefined) {
|
||||
await this.modelsStore.write(providerId, structuredClone(publication.persist), { signal });
|
||||
}
|
||||
|
||||
if (signal.aborted || this.refreshGenerations.get(providerId) !== generation) return false;
|
||||
publication.update?.();
|
||||
return true;
|
||||
})();
|
||||
const tail = queued.catch(() => {});
|
||||
this.publicationChains.set(providerId, tail);
|
||||
void tail.then(() => {
|
||||
if (this.publicationChains.get(providerId) === tail) this.publicationChains.delete(providerId);
|
||||
});
|
||||
return raceWithAbortSignal(queued, signal);
|
||||
}
|
||||
|
||||
private async runProviderRefreshPhase(
|
||||
provider: Provider & Required<Pick<Provider, "refreshModels">>,
|
||||
credential: Credential | undefined,
|
||||
allowNetwork: boolean,
|
||||
force: boolean | undefined,
|
||||
generation: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const stored = await this.modelsStore.read(provider.id, { signal });
|
||||
await provider.refreshModels({
|
||||
credential,
|
||||
stored: stored ? structuredClone(stored) : undefined,
|
||||
publish: (publication) => this.publishProviderModels(provider.id, generation, signal, publication),
|
||||
allowNetwork,
|
||||
force: allowNetwork ? force : undefined,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
async refresh(options: ModelsRefreshOptions = {}): Promise<ModelsRefreshResult> {
|
||||
const allowNetwork = options.allowNetwork ?? true;
|
||||
const callerSignal = operationSignal(options.signal);
|
||||
const errors = new Map<string, Error>();
|
||||
if (callerSignal.aborted) return { aborted: true, errors };
|
||||
const selected = options.providers ? new Set(options.providers) : undefined;
|
||||
const refreshable = Array.from(this.providers.values()).filter(
|
||||
(provider): provider is Provider & Required<Pick<Provider, "refreshModels">> =>
|
||||
provider.refreshModels !== undefined && (!selected || selected.has(provider.id)),
|
||||
);
|
||||
|
||||
const refresh = Promise.all(
|
||||
refreshable.map(async (provider) => {
|
||||
const { generation, controller } = this.beginProviderRefresh(provider.id);
|
||||
const signal = AbortSignal.any([callerSignal, controller.signal]);
|
||||
const operation = (async () => {
|
||||
let storedCredential: Credential | undefined;
|
||||
let credentialError: unknown;
|
||||
try {
|
||||
storedCredential = await this.readCredential(provider.id, signal);
|
||||
} catch (error) {
|
||||
credentialError = error;
|
||||
}
|
||||
|
||||
// Restore cached provider state before auth resolution or network access.
|
||||
await this.runProviderRefreshPhase(provider, storedCredential, false, undefined, generation, signal);
|
||||
if (credentialError !== undefined) throw credentialError;
|
||||
if (!allowNetwork || signal.aborted) return;
|
||||
|
||||
const credential = await this.resolveRefreshCredential(provider, storedCredential, signal);
|
||||
if (!credential) return;
|
||||
await this.runProviderRefreshPhase(provider, credential, true, options.force, generation, signal);
|
||||
})();
|
||||
|
||||
try {
|
||||
await raceWithAbortSignal(operation, signal);
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
errors.set(
|
||||
provider.id,
|
||||
error instanceof Error
|
||||
? error
|
||||
: new ModelsError("model_source", `Model refresh failed for ${provider.id}`, { cause: error }),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (this.refreshControllers.get(provider.id) === controller) {
|
||||
this.refreshControllers.delete(provider.id);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await raceWithAbortSignal(refresh, callerSignal);
|
||||
} catch (error) {
|
||||
if (!callerSignal.aborted) throw error;
|
||||
}
|
||||
|
||||
return { aborted: callerSignal.aborted, errors: new Map(errors) };
|
||||
}
|
||||
|
||||
private async resolveRefreshCredential(
|
||||
provider: Provider,
|
||||
stored: Credential | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<Credential | undefined> {
|
||||
if (stored?.type === "oauth") {
|
||||
const oauth = provider.auth.oauth;
|
||||
if (!oauth) return undefined;
|
||||
if (Date.now() < stored.expires) return stored;
|
||||
if (signal.aborted) return undefined;
|
||||
const post = await this.credentials.modify(
|
||||
provider.id,
|
||||
async (current) => {
|
||||
if (current?.type !== "oauth" || Date.now() < current.expires) return undefined;
|
||||
return oauth.refresh(current, signal);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
return post?.type === "oauth" ? post : undefined;
|
||||
}
|
||||
|
||||
const apiKey = provider.auth.apiKey;
|
||||
if (!apiKey) return undefined;
|
||||
const credential = stored?.type === "api_key" ? stored : undefined;
|
||||
const result = await apiKey.resolve({ ctx: this.authContext, credential, signal });
|
||||
if (!result) return undefined;
|
||||
return { type: "api_key", key: result.auth.apiKey, env: result.env };
|
||||
}
|
||||
|
||||
private async readCredential(providerId: string, signal: AbortSignal): Promise<Credential | undefined> {
|
||||
try {
|
||||
return await this.credentials.read(providerId, { signal });
|
||||
} catch (error) {
|
||||
throw new ModelsError("auth", `Credential store read failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
private async checkProviderAuth(
|
||||
provider: Provider,
|
||||
credential: Credential | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthCheck | undefined> {
|
||||
if (credential?.type === "oauth") {
|
||||
return provider.auth.oauth ? { source: "OAuth", type: "oauth" } : undefined;
|
||||
}
|
||||
const apiKey = provider.auth.apiKey;
|
||||
if (!apiKey) return undefined;
|
||||
if (apiKey.check) {
|
||||
try {
|
||||
return await apiKey.check({
|
||||
ctx: this.authContext,
|
||||
credential: credential?.type === "api_key" ? credential : undefined,
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ModelsError("auth", `API key auth check failed for provider ${provider.id}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
const resolution = await resolveProviderAuth(provider, this.credentials, this.authContext, { signal });
|
||||
return resolution ? { source: resolution.source, type: "api_key" } : undefined;
|
||||
}
|
||||
|
||||
checkAuth(providerId: string, options?: AuthOperationOptions): Promise<AuthCheck | undefined> {
|
||||
const signal = operationSignal(options?.signal);
|
||||
const check = (async () => {
|
||||
signal.throwIfAborted();
|
||||
const provider = this.providers.get(providerId);
|
||||
if (!provider) return undefined;
|
||||
return this.checkProviderAuth(provider, await this.readCredential(providerId, signal), signal);
|
||||
})();
|
||||
return raceWithAbortSignal(check, signal);
|
||||
}
|
||||
|
||||
getAvailable(providerId?: string, options?: AuthOperationOptions): Promise<readonly Model<Api>[]> {
|
||||
const signal = operationSignal(options?.signal);
|
||||
const available = (async () => {
|
||||
signal.throwIfAborted();
|
||||
const providers = providerId
|
||||
? [this.providers.get(providerId)].filter((entry) => entry !== undefined)
|
||||
: this.getProviders();
|
||||
const checks = await Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
const credential = await this.readCredential(provider.id, signal);
|
||||
return { provider, credential, auth: await this.checkProviderAuth(provider, credential, signal) };
|
||||
}),
|
||||
);
|
||||
return checks.flatMap(({ provider, credential, auth }) => {
|
||||
if (!auth) return [];
|
||||
const models = provider.getModels();
|
||||
return provider.filterModels?.(models, credential) ?? models;
|
||||
});
|
||||
})();
|
||||
return raceWithAbortSignal(available, signal);
|
||||
}
|
||||
|
||||
getAuth(providerId: string, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
getAuth(model: Model<Api>, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
async getAuth(
|
||||
providerOrModel: string | Model<Api>,
|
||||
overrides?: AuthResolutionOverrides,
|
||||
): Promise<AuthResult | undefined> {
|
||||
const signal = operationSignal(overrides?.signal);
|
||||
const providerId = typeof providerOrModel === "string" ? providerOrModel : providerOrModel.provider;
|
||||
const provider = this.providers.get(providerId);
|
||||
if (!provider) return undefined;
|
||||
const result = await resolveProviderAuth(provider, this.credentials, this.authContext, { ...overrides, signal });
|
||||
if (!result || typeof providerOrModel === "string" || !providerOrModel.headers) return result;
|
||||
return {
|
||||
...result,
|
||||
auth: {
|
||||
...result.auth,
|
||||
headers: mergeHeaders(result.auth.headers, providerOrModel.headers),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
|
||||
const signal = operationSignal(interaction.signal);
|
||||
signal.throwIfAborted();
|
||||
const provider = this.providers.get(providerId);
|
||||
if (!provider) throw new ModelsError("provider", `Unknown provider: ${providerId}`);
|
||||
const method = type === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
|
||||
if (!method?.login) {
|
||||
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
|
||||
}
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal });
|
||||
const credential = await raceWithAbortSignal(loginOperation, signal);
|
||||
let mutationStarted = false;
|
||||
let markMutationStarted: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markMutationStarted = resolve;
|
||||
});
|
||||
const mutation = this.credentials.modify(
|
||||
providerId,
|
||||
async () => {
|
||||
mutationStarted = true;
|
||||
markMutationStarted?.();
|
||||
return credential;
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
void mutation.catch(() => {});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
if (!mutationStarted) reject(signal.reason);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
void Promise.race([started, mutation]).then(
|
||||
() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
if (signal.aborted) onAbort();
|
||||
});
|
||||
await mutation;
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw new ModelsError("auth", `Credential store modify failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
async logout(providerId: string, options?: AuthOperationOptions): Promise<void> {
|
||||
const signal = operationSignal(options?.signal);
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
await this.credentials.delete(providerId, { signal });
|
||||
} catch (error) {
|
||||
signal.throwIfAborted();
|
||||
throw new ModelsError("auth", `Credential store delete failed for ${providerId}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
private requireProvider(model: Model<Api>): Provider {
|
||||
const provider = this.providers.get(model.provider);
|
||||
if (!provider) {
|
||||
throw new ModelsError("provider", `Unknown provider: ${model.provider}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
private async applyAuth<TOptions extends ProviderRequestOptions & ModelsRequestTransforms>(
|
||||
model: Model<Api>,
|
||||
options: TOptions | undefined,
|
||||
): Promise<{
|
||||
requestModel: Model<Api>;
|
||||
requestOptions: Omit<TOptions, "transformHeaders"> & ProviderRequestOptions;
|
||||
}> {
|
||||
this.requireProvider(model);
|
||||
const resolution = await this.getAuth(model, {
|
||||
apiKey: options?.apiKey,
|
||||
env: options?.env,
|
||||
signal: options?.signal,
|
||||
});
|
||||
if (!resolution) {
|
||||
throw new ModelsError("auth", `Provider is not configured: ${model.provider}`);
|
||||
}
|
||||
const auth = resolution.auth;
|
||||
|
||||
// Explicit request options win per-field; the Models-only transform runs last.
|
||||
const apiKey = options?.apiKey ?? auth.apiKey;
|
||||
let headers = mergeHeaders(auth.headers, options?.headers);
|
||||
if (options?.transformHeaders) headers = await options.transformHeaders(headers ?? {});
|
||||
const env = resolution.env || options?.env ? { ...(resolution.env ?? {}), ...(options?.env ?? {}) } : undefined;
|
||||
const requestModel = auth.baseUrl ? { ...model, baseUrl: auth.baseUrl } : model;
|
||||
const { transformHeaders: _transformHeaders, ...providerOptions } = options ?? {};
|
||||
const requestOptions = { ...providerOptions, apiKey, headers, env } as Omit<TOptions, "transformHeaders"> &
|
||||
ProviderRequestOptions;
|
||||
|
||||
return { requestModel, requestOptions };
|
||||
}
|
||||
|
||||
stream<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ModelsApiStreamOptions<TApi>,
|
||||
): AssistantMessageEventStream {
|
||||
return lazyStream(model, async () => {
|
||||
const provider = this.requireProvider(model);
|
||||
const { requestModel, requestOptions } = await this.applyAuth(
|
||||
model,
|
||||
options as ModelsApiStreamOptions<Api> | undefined,
|
||||
);
|
||||
return provider.stream(requestModel as Model<TApi>, context, requestOptions as ApiStreamOptions<TApi>);
|
||||
});
|
||||
}
|
||||
|
||||
async complete<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
context: Context,
|
||||
options?: ModelsApiStreamOptions<TApi>,
|
||||
): Promise<AssistantMessage> {
|
||||
return this.stream(model, context, options).result();
|
||||
}
|
||||
|
||||
streamSimple(model: Model<Api>, context: Context, options?: ModelsSimpleStreamOptions): AssistantMessageEventStream {
|
||||
return lazyStream(model, async () => {
|
||||
const provider = this.requireProvider(model);
|
||||
const { requestModel, requestOptions } = await this.applyAuth(model, options);
|
||||
return provider.streamSimple(requestModel, context, requestOptions as SimpleStreamOptions);
|
||||
});
|
||||
}
|
||||
|
||||
async completeSimple(
|
||||
model: Model<Api>,
|
||||
context: Context,
|
||||
options?: ModelsSimpleStreamOptions,
|
||||
): Promise<AssistantMessage> {
|
||||
return this.streamSimple(model, context, options).result();
|
||||
}
|
||||
|
||||
streamDeferred(
|
||||
model: Model<Api>,
|
||||
handle: DeferredHandle,
|
||||
options?: ModelsDeferredFetchOptions,
|
||||
): AssistantMessageEventStream {
|
||||
return lazyStream(model, async () => {
|
||||
const provider = this.requireProvider(model);
|
||||
if (!provider.fetchDeferred) {
|
||||
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
|
||||
}
|
||||
const { requestModel, requestOptions } = await this.applyAuth(model, options);
|
||||
return provider.fetchDeferred(requestModel, handle, requestOptions as DeferredFetchOptions);
|
||||
});
|
||||
}
|
||||
|
||||
async fetchDeferred(
|
||||
model: Model<Api>,
|
||||
handle: DeferredHandle,
|
||||
options?: ModelsDeferredFetchOptions,
|
||||
): Promise<AssistantMessage> {
|
||||
return this.streamDeferred(model, handle, options).result();
|
||||
}
|
||||
|
||||
async cancelDeferred(
|
||||
model: Model<Api>,
|
||||
handle: DeferredHandle,
|
||||
options?: ModelsDeferredCancelOptions,
|
||||
): Promise<void> {
|
||||
const provider = this.requireProvider(model);
|
||||
if (!provider.cancelDeferred) {
|
||||
throw new ModelsError("provider", `Provider ${model.provider} does not support deferred responses`);
|
||||
}
|
||||
const { requestModel, requestOptions } = await this.applyAuth(model, options);
|
||||
await provider.cancelDeferred(requestModel, handle, requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
export function createModels(options?: CreateModelsOptions): MutableModels {
|
||||
return new ModelsImpl(options);
|
||||
}
|
||||
|
||||
export interface CreateProviderOptions<TApi extends Api = Api> {
|
||||
id: string;
|
||||
/** Display name. Default: `id`. */
|
||||
name?: string;
|
||||
baseUrl?: string;
|
||||
headers?: ProviderHeaders;
|
||||
/** Required — every provider has auth semantics, even ambient/keyless ones. */
|
||||
auth: ProviderAuth;
|
||||
/** Static baseline model list (empty for purely dynamic providers). */
|
||||
models: readonly Model<TApi>[];
|
||||
/** Fetch a dynamic model overlay. createProvider restores and publishes it transactionally. */
|
||||
fetchModels?: (context: RefreshModelsContext) => Promise<readonly Model<TApi>[]>;
|
||||
filterModels?: (models: readonly Model<TApi>[], credential: Credential | undefined) => readonly Model<TApi>[];
|
||||
/** Single implementation, or map keyed by `model.api` for mixed-API providers. */
|
||||
api: ProviderStreams | Partial<Record<TApi, ProviderStreams>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a provider from parts. Built-in provider factories and models.json
|
||||
* custom providers both go through this. A single `api` streams all models;
|
||||
* an `api` map dispatches on `model.api`, and a model whose api has no entry
|
||||
* produces a stream error.
|
||||
*/
|
||||
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
|
||||
const baselineModels = input.models;
|
||||
let dynamicModels: readonly Model<TApi>[] = [];
|
||||
const fetchModels = input.fetchModels;
|
||||
const currentModels = (): readonly Model<TApi>[] => {
|
||||
const merged = [...baselineModels];
|
||||
for (const model of dynamicModels) {
|
||||
const index = merged.findIndex((entry) => entry.id === model.id);
|
||||
if (index >= 0) merged[index] = model;
|
||||
else merged.push(model);
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
const single =
|
||||
typeof (input.api as ProviderStreams).stream === "function" ? (input.api as ProviderStreams) : undefined;
|
||||
const byApi = single ? undefined : (input.api as Partial<Record<string, ProviderStreams>>);
|
||||
|
||||
const apiFor = (model: Model<Api>): ProviderStreams | undefined => single ?? byApi?.[model.api];
|
||||
|
||||
const dispatch = (
|
||||
model: Model<Api>,
|
||||
run: (streams: ProviderStreams) => AssistantMessageEventStream,
|
||||
): AssistantMessageEventStream => {
|
||||
const streams = apiFor(model);
|
||||
if (!streams) {
|
||||
return lazyStream(model, async () => {
|
||||
throw new ModelsError("stream", `Provider ${input.id} has no API implementation for "${model.api}"`);
|
||||
});
|
||||
}
|
||||
return run(streams);
|
||||
};
|
||||
|
||||
const provider: Provider<TApi> = {
|
||||
id: input.id,
|
||||
name: input.name ?? input.id,
|
||||
baseUrl: input.baseUrl,
|
||||
headers: input.headers,
|
||||
auth: input.auth,
|
||||
getModels: currentModels,
|
||||
refreshModels: fetchModels
|
||||
? async (context) => {
|
||||
if (context.stored) {
|
||||
const restored = context.stored.models
|
||||
.filter((model) => model.provider === input.id)
|
||||
.map((model) => model as Model<TApi>);
|
||||
if (
|
||||
!(await context.publish({
|
||||
update: () => {
|
||||
dynamicModels = restored;
|
||||
},
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!context.allowNetwork || context.signal.aborted) return;
|
||||
const refreshed = await fetchModels(context);
|
||||
if (context.signal.aborted) return;
|
||||
await context.publish({
|
||||
persist: { models: refreshed, checkedAt: Date.now() },
|
||||
update: () => {
|
||||
dynamicModels = refreshed;
|
||||
},
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
filterModels: input.filterModels,
|
||||
stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
|
||||
streamSimple: (model, context, options) =>
|
||||
dispatch(model, (streams) => streams.streamSimple(model, context, options)),
|
||||
};
|
||||
|
||||
const streams = single ? [single] : Object.values(byApi ?? {}).filter((entry) => entry !== undefined);
|
||||
if (streams.some((entry) => entry.fetchDeferred !== undefined)) {
|
||||
provider.fetchDeferred = (model, handle, options) =>
|
||||
lazyStream(model, async () => {
|
||||
const implementation = apiFor(model);
|
||||
if (!implementation?.fetchDeferred) {
|
||||
throw new ModelsError(
|
||||
"provider",
|
||||
`Provider ${input.id} does not support deferred responses for "${model.api}"`,
|
||||
);
|
||||
}
|
||||
return implementation.fetchDeferred(model, handle, options);
|
||||
});
|
||||
}
|
||||
if (streams.some((entry) => entry.cancelDeferred !== undefined)) {
|
||||
provider.cancelDeferred = async (model, handle, options) => {
|
||||
const implementation = apiFor(model);
|
||||
if (!implementation?.cancelDeferred) {
|
||||
throw new ModelsError(
|
||||
"provider",
|
||||
`Provider ${input.id} cannot cancel deferred responses for "${model.api}"`,
|
||||
);
|
||||
}
|
||||
await implementation.cancelDeferred(model, handle, options);
|
||||
};
|
||||
}
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-checked narrowing for dynamically looked-up models:
|
||||
*
|
||||
* ```ts
|
||||
* const model = models.getModel("anthropic", "claude-opus-4-7");
|
||||
* if (model && hasApi(model, "anthropic-messages")) {
|
||||
* // model: Model<"anthropic-messages">, stream options fully typed
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function hasApi<TApi extends Api>(model: Model<Api>, api: TApi): model is Model<TApi> {
|
||||
return model.api === api;
|
||||
}
|
||||
|
||||
export function calculateCost<TApi extends Api>(model: Model<TApi>, usage: Usage): Usage["cost"] {
|
||||
const inputTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
||||
let rates: ModelCostRates = model.cost;
|
||||
let matchedThreshold = -1;
|
||||
for (const tier of model.cost.tiers ?? []) {
|
||||
if (inputTokens > tier.inputTokensAbove && tier.inputTokensAbove > matchedThreshold) {
|
||||
rates = tier;
|
||||
matchedThreshold = tier.inputTokensAbove;
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic charges 2x base input for 1h cache writes.
|
||||
const longWrite = usage.cacheWrite1h ?? 0;
|
||||
const shortWrite = usage.cacheWrite - longWrite;
|
||||
usage.cost.input = (rates.input / 1000000) * usage.input;
|
||||
usage.cost.output = (rates.output / 1000000) * usage.output;
|
||||
usage.cost.cacheRead = (rates.cacheRead / 1000000) * usage.cacheRead;
|
||||
usage.cost.cacheWrite = (rates.cacheWrite * shortWrite + rates.input * 2 * longWrite) / 1000000;
|
||||
usage.cost.total = usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
|
||||
return usage.cost;
|
||||
}
|
||||
|
||||
const EXTENDED_THINKING_LEVELS: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
export function getSupportedThinkingLevels<TApi extends Api>(model: Model<TApi>): ModelThinkingLevel[] {
|
||||
if (!model.reasoning) return ["off"];
|
||||
|
||||
return EXTENDED_THINKING_LEVELS.filter((level) => {
|
||||
const mapped = model.thinkingLevelMap?.[level];
|
||||
if (mapped === null) return false;
|
||||
if (level === "xhigh" || level === "max") return mapped !== undefined;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function clampThinkingLevel<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
level: ModelThinkingLevel,
|
||||
): ModelThinkingLevel {
|
||||
const availableLevels = getSupportedThinkingLevels(model);
|
||||
if (availableLevels.includes(level)) return level;
|
||||
|
||||
const requestedIndex = EXTENDED_THINKING_LEVELS.indexOf(level);
|
||||
if (requestedIndex === -1) return availableLevels[0] ?? "off";
|
||||
|
||||
for (let i = requestedIndex; i < EXTENDED_THINKING_LEVELS.length; i++) {
|
||||
const candidate = EXTENDED_THINKING_LEVELS[i];
|
||||
if (availableLevels.includes(candidate)) return candidate;
|
||||
}
|
||||
for (let i = requestedIndex - 1; i >= 0; i--) {
|
||||
const candidate = EXTENDED_THINKING_LEVELS[i];
|
||||
if (availableLevels.includes(candidate)) return candidate;
|
||||
}
|
||||
return availableLevels[0] ?? "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two models are equal by comparing both their id and provider.
|
||||
* Returns false if either model is null or undefined.
|
||||
*/
|
||||
export function modelsAreEqual<TApi extends Api>(
|
||||
a: Model<TApi> | null | undefined,
|
||||
b: Model<TApi> | null | undefined,
|
||||
): boolean {
|
||||
if (!a || !b) return false;
|
||||
return a.id === b.id && a.provider === b.provider;
|
||||
}
|
||||
10
packages/ai/src/oauth.ts
Normal file
10
packages/ai/src/oauth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Type-only compatibility entry point for coding-agent extension OAuth declarations. */
|
||||
export type {
|
||||
OAuthAuthInfo,
|
||||
OAuthCredentials,
|
||||
OAuthDeviceCodeInfo,
|
||||
OAuthLoginCallbacks,
|
||||
OAuthPrompt,
|
||||
OAuthSelectOption,
|
||||
OAuthSelectPrompt,
|
||||
} from "./compat/extension-oauth-types.ts";
|
||||
155
packages/ai/src/providers/all.ts
Normal file
155
packages/ai/src/providers/all.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { createImagesModels, type ImagesProvider, type MutableImagesModels } from "../images-models.ts";
|
||||
import { MODELS } from "../models.generated.ts";
|
||||
import { type CreateModelsOptions, createModels, type MutableModels, type Provider } from "../models.ts";
|
||||
import type { Api, Model } from "../types.ts";
|
||||
import { amazonBedrockProvider } from "./amazon-bedrock.ts";
|
||||
import { antLingProvider } from "./ant-ling.ts";
|
||||
import { anthropicProvider } from "./anthropic.ts";
|
||||
import { azureOpenAIResponsesProvider } from "./azure-openai-responses.ts";
|
||||
import { basetenProvider } from "./baseten.ts";
|
||||
import { cerebrasProvider } from "./cerebras.ts";
|
||||
import { cloudflareAIGatewayProvider } from "./cloudflare-ai-gateway.ts";
|
||||
import { cloudflareWorkersAIProvider } from "./cloudflare-workers-ai.ts";
|
||||
import modelDataManifest from "./data/.manifest.json" with { type: "json" };
|
||||
import { deepseekProvider } from "./deepseek.ts";
|
||||
import { fireworksProvider } from "./fireworks.ts";
|
||||
import { githubCopilotProvider } from "./github-copilot.ts";
|
||||
import { googleProvider } from "./google.ts";
|
||||
import { googleVertexProvider } from "./google-vertex.ts";
|
||||
import { groqProvider } from "./groq.ts";
|
||||
import { huggingfaceProvider } from "./huggingface.ts";
|
||||
import { kimiCodingProvider } from "./kimi-coding.ts";
|
||||
import { minimaxProvider } from "./minimax.ts";
|
||||
import { minimaxCnProvider } from "./minimax-cn.ts";
|
||||
import { mistralProvider } from "./mistral.ts";
|
||||
import { moonshotaiProvider } from "./moonshotai.ts";
|
||||
import { moonshotaiCnProvider } from "./moonshotai-cn.ts";
|
||||
import { nvidiaProvider } from "./nvidia.ts";
|
||||
import { openaiProvider } from "./openai.ts";
|
||||
import { openaiCodexProvider } from "./openai-codex.ts";
|
||||
import { opencodeProvider } from "./opencode.ts";
|
||||
import { opencodeGoProvider } from "./opencode-go.ts";
|
||||
import { openrouterProvider } from "./openrouter.ts";
|
||||
import { openrouterImagesProvider } from "./openrouter-images.ts";
|
||||
import { qwenTokenPlanProvider } from "./qwen-token-plan.ts";
|
||||
import { qwenTokenPlanCnProvider } from "./qwen-token-plan-cn.ts";
|
||||
import { qwenTokenPlanIndividualProvider } from "./qwen-token-plan-individual.ts";
|
||||
import { radiusProvider } from "./radius.ts";
|
||||
import { togetherProvider } from "./together.ts";
|
||||
import { vercelAIGatewayProvider } from "./vercel-ai-gateway.ts";
|
||||
import { xaiProvider } from "./xai.ts";
|
||||
import { xiaomiProvider } from "./xiaomi.ts";
|
||||
import { xiaomiTokenPlanAmsProvider } from "./xiaomi-token-plan-ams.ts";
|
||||
import { xiaomiTokenPlanCnProvider } from "./xiaomi-token-plan-cn.ts";
|
||||
import { xiaomiTokenPlanSgpProvider } from "./xiaomi-token-plan-sgp.ts";
|
||||
import { zaiProvider } from "./zai.ts";
|
||||
import { zaiCodingCnProvider } from "./zai-coding-cn.ts";
|
||||
|
||||
export { radiusProvider };
|
||||
|
||||
/** Providers present in the generated catalog. `KnownProvider` additionally
|
||||
* includes purely dynamic providers (e.g. "radius") that have no static
|
||||
* catalog entry. */
|
||||
export type BuiltinProvider = keyof typeof MODELS;
|
||||
|
||||
type BuiltinModelApi<
|
||||
TProvider extends BuiltinProvider,
|
||||
TModelId extends keyof (typeof MODELS)[TProvider],
|
||||
> = (typeof MODELS)[TProvider][TModelId] extends { api: infer TApi } ? (TApi extends Api ? TApi : never) : never;
|
||||
|
||||
/** Typed read of the generated built-in catalog. */
|
||||
export function getBuiltinModel<TProvider extends BuiltinProvider, TModelId extends keyof (typeof MODELS)[TProvider]>(
|
||||
provider: TProvider,
|
||||
modelId: TModelId,
|
||||
): Model<BuiltinModelApi<TProvider, TModelId>> {
|
||||
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
|
||||
return models?.[modelId as string] as Model<BuiltinModelApi<TProvider, TModelId>>;
|
||||
}
|
||||
|
||||
export function getBuiltinProviders(): BuiltinProvider[] {
|
||||
return Object.keys(MODELS) as BuiltinProvider[];
|
||||
}
|
||||
|
||||
/** Generation timestamp shared by all built-in provider catalogs. */
|
||||
export function getBuiltinModelDataGeneratedAt(): number | undefined {
|
||||
const generatedAt = Date.parse(modelDataManifest.generatedAt);
|
||||
return Number.isNaN(generatedAt) ? undefined : generatedAt;
|
||||
}
|
||||
|
||||
export function getBuiltinModels<TProvider extends BuiltinProvider>(
|
||||
provider: TProvider,
|
||||
): Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[] {
|
||||
const models = MODELS[provider] as Record<string, Model<Api>> | undefined;
|
||||
return models
|
||||
? (Object.values(models) as Model<BuiltinModelApi<TProvider, keyof (typeof MODELS)[TProvider]>>[])
|
||||
: [];
|
||||
}
|
||||
|
||||
/** All built-in providers, freshly constructed. */
|
||||
export function builtinProviders(): Provider[] {
|
||||
return [
|
||||
amazonBedrockProvider(),
|
||||
antLingProvider(),
|
||||
anthropicProvider(),
|
||||
azureOpenAIResponsesProvider(),
|
||||
basetenProvider(),
|
||||
cerebrasProvider(),
|
||||
cloudflareAIGatewayProvider(),
|
||||
cloudflareWorkersAIProvider(),
|
||||
deepseekProvider(),
|
||||
fireworksProvider(),
|
||||
githubCopilotProvider(),
|
||||
googleProvider(),
|
||||
googleVertexProvider(),
|
||||
groqProvider(),
|
||||
huggingfaceProvider(),
|
||||
kimiCodingProvider(),
|
||||
minimaxProvider(),
|
||||
minimaxCnProvider(),
|
||||
mistralProvider(),
|
||||
moonshotaiProvider(),
|
||||
moonshotaiCnProvider(),
|
||||
nvidiaProvider(),
|
||||
openaiProvider(),
|
||||
openaiCodexProvider(),
|
||||
opencodeProvider(),
|
||||
opencodeGoProvider(),
|
||||
openrouterProvider(),
|
||||
qwenTokenPlanProvider(),
|
||||
qwenTokenPlanCnProvider(),
|
||||
qwenTokenPlanIndividualProvider(),
|
||||
radiusProvider(),
|
||||
togetherProvider(),
|
||||
vercelAIGatewayProvider(),
|
||||
xaiProvider(),
|
||||
xiaomiProvider(),
|
||||
xiaomiTokenPlanAmsProvider(),
|
||||
xiaomiTokenPlanCnProvider(),
|
||||
xiaomiTokenPlanSgpProvider(),
|
||||
zaiProvider(),
|
||||
zaiCodingCnProvider(),
|
||||
];
|
||||
}
|
||||
|
||||
/** A `Models` collection with every built-in provider registered. */
|
||||
export function builtinModels(options?: CreateModelsOptions): MutableModels {
|
||||
const models = createModels(options);
|
||||
for (const provider of builtinProviders()) {
|
||||
models.setProvider(provider);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
/** All built-in image-generation providers, freshly constructed. */
|
||||
export function builtinImagesProviders(): ImagesProvider[] {
|
||||
return [openrouterImagesProvider()];
|
||||
}
|
||||
|
||||
/** An `ImagesModels` collection with every built-in image-generation provider registered. */
|
||||
export function builtinImagesModels(options?: CreateModelsOptions): MutableImagesModels {
|
||||
const models = createImagesModels(options);
|
||||
for (const provider of builtinImagesProviders()) {
|
||||
models.setProvider(provider);
|
||||
}
|
||||
return models;
|
||||
}
|
||||
8
packages/ai/src/providers/amazon-bedrock.models.ts
Normal file
8
packages/ai/src/providers/amazon-bedrock.models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/amazon-bedrock.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const AMAZON_BEDROCK_MODELS: ModelCatalog<typeof values, "amazon-bedrock"> =
|
||||
flattenModelCatalog("amazon-bedrock", values);
|
||||
90
packages/ai/src/providers/amazon-bedrock.ts
Normal file
90
packages/ai/src/providers/amazon-bedrock.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { bedrockConverseStreamApi } from "../api/bedrock-converse-stream.lazy.ts";
|
||||
import type { ApiKeyAuth } from "../auth/types.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { AMAZON_BEDROCK_MODELS } from "./amazon-bedrock.models.ts";
|
||||
|
||||
/**
|
||||
* Bedrock accepts a bearer token or the AWS SDK's default credential chain.
|
||||
* The login flow can store a token/profile choice; resolve also detects ambient
|
||||
* AWS credentials without copying them into pi's credential store.
|
||||
*/
|
||||
const bedrockAuth: ApiKeyAuth = {
|
||||
name: "AWS credentials or bearer token",
|
||||
login: async (interaction) => {
|
||||
interaction.signal.throwIfAborted();
|
||||
const method = await interaction.prompt({
|
||||
type: "select",
|
||||
message: "Select Amazon Bedrock authentication method:",
|
||||
options: [
|
||||
{ id: "bearer-token", label: "Bearer token" },
|
||||
{ id: "aws-profile", label: "AWS profile" },
|
||||
{ id: "credential-chain", label: "Existing AWS credential chain" },
|
||||
],
|
||||
});
|
||||
interaction.signal.throwIfAborted();
|
||||
if (method === "bearer-token") {
|
||||
return {
|
||||
type: "api_key",
|
||||
key: await interaction.prompt({ type: "secret", message: "Enter Amazon Bedrock bearer token" }),
|
||||
};
|
||||
}
|
||||
interaction.notify({
|
||||
type: "info",
|
||||
message: "Amazon Bedrock supports AWS profiles, IAM credentials, and role-based credentials.",
|
||||
links: [
|
||||
{
|
||||
label: "AWS credential provider chain",
|
||||
url: "https://docs.aws.amazon.com/sdkref/latest/guide/standardized-credentials.html",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (method === "aws-profile") {
|
||||
return {
|
||||
type: "api_key",
|
||||
env: { AWS_PROFILE: await interaction.prompt({ type: "text", message: "Enter AWS profile name" }) },
|
||||
};
|
||||
}
|
||||
if (method !== "credential-chain") throw new Error(`Unknown Amazon Bedrock auth method: ${method}`);
|
||||
await interaction.prompt({
|
||||
type: "text",
|
||||
message: "Configure AWS credentials, then press Enter to continue",
|
||||
});
|
||||
return { type: "api_key" };
|
||||
},
|
||||
resolve: async ({ ctx, credential, signal }) => {
|
||||
const env = async (name: string) => {
|
||||
signal.throwIfAborted();
|
||||
const value = await ctx.env(name);
|
||||
signal.throwIfAborted();
|
||||
return value;
|
||||
};
|
||||
if (credential?.key) {
|
||||
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
|
||||
}
|
||||
if (await env("AWS_BEARER_TOKEN_BEDROCK")) return { auth: {}, source: "AWS_BEARER_TOKEN_BEDROCK" };
|
||||
if (credential?.env?.AWS_PROFILE ?? (await env("AWS_PROFILE"))) {
|
||||
return {
|
||||
auth: {},
|
||||
env: credential?.env,
|
||||
source: credential?.env?.AWS_PROFILE ? "stored credential" : "AWS_PROFILE",
|
||||
};
|
||||
}
|
||||
if ((await env("AWS_ACCESS_KEY_ID")) && (await env("AWS_SECRET_ACCESS_KEY"))) {
|
||||
return { auth: {}, source: "AWS access keys" };
|
||||
}
|
||||
if (await env("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")) return { auth: {}, source: "ECS task role" };
|
||||
if (await env("AWS_CONTAINER_CREDENTIALS_FULL_URI")) return { auth: {}, source: "ECS task role" };
|
||||
if (await env("AWS_WEB_IDENTITY_TOKEN_FILE")) return { auth: {}, source: "web identity token" };
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
export function amazonBedrockProvider(): Provider<"bedrock-converse-stream"> {
|
||||
return createProvider({
|
||||
id: "amazon-bedrock",
|
||||
name: "Amazon Bedrock",
|
||||
auth: { apiKey: bedrockAuth },
|
||||
models: Object.values(AMAZON_BEDROCK_MODELS),
|
||||
api: bedrockConverseStreamApi(),
|
||||
});
|
||||
}
|
||||
8
packages/ai/src/providers/ant-ling.models.ts
Normal file
8
packages/ai/src/providers/ant-ling.models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/ant-ling.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const ANT_LING_MODELS: ModelCatalog<typeof values, "ant-ling"> =
|
||||
flattenModelCatalog("ant-ling", values);
|
||||
15
packages/ai/src/providers/ant-ling.ts
Normal file
15
packages/ai/src/providers/ant-ling.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { ANT_LING_MODELS } from "./ant-ling.models.ts";
|
||||
|
||||
export function antLingProvider(): Provider<"openai-completions"> {
|
||||
return createProvider({
|
||||
id: "ant-ling",
|
||||
name: "Ant Ling",
|
||||
baseUrl: "https://api.ant-ling.com/v1",
|
||||
auth: { apiKey: envApiKeyAuth("Ant Ling API key", ["ANT_LING_API_KEY"]) },
|
||||
models: Object.values(ANT_LING_MODELS),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
}
|
||||
8
packages/ai/src/providers/anthropic.models.ts
Normal file
8
packages/ai/src/providers/anthropic.models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/anthropic.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const ANTHROPIC_MODELS: ModelCatalog<typeof values, "anthropic"> =
|
||||
flattenModelCatalog("anthropic", values);
|
||||
59
packages/ai/src/providers/anthropic.ts
Normal file
59
packages/ai/src/providers/anthropic.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
||||
import { lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadAnthropicOAuth } from "../auth/oauth/load.ts";
|
||||
import type { ApiKeyAuth } from "../auth/types.ts";
|
||||
import { ANTHROPIC_API_KEY_ENV, ANTHROPIC_AUTH_TOKEN_ENV, ANTHROPIC_OAUTH_TOKEN_ENV } from "../env-api-keys.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { ANTHROPIC_MODELS } from "./anthropic.models.ts";
|
||||
|
||||
function anthropicApiKeyAuth(): ApiKeyAuth {
|
||||
return {
|
||||
name: "Anthropic API key",
|
||||
login: async (interaction) => {
|
||||
interaction.signal.throwIfAborted();
|
||||
const key = await interaction.prompt({ type: "secret", message: "Enter Anthropic API key" });
|
||||
interaction.signal.throwIfAborted();
|
||||
return { type: "api_key", key };
|
||||
},
|
||||
resolve: async ({ ctx, credential, signal }) => {
|
||||
signal.throwIfAborted();
|
||||
if (credential?.key) {
|
||||
return { auth: { apiKey: credential.key }, env: credential.env, source: "stored credential" };
|
||||
}
|
||||
|
||||
const authToken = await ctx.env(ANTHROPIC_AUTH_TOKEN_ENV);
|
||||
signal.throwIfAborted();
|
||||
if (authToken) {
|
||||
return {
|
||||
auth: { headers: { Authorization: `Bearer ${authToken}` } },
|
||||
source: ANTHROPIC_AUTH_TOKEN_ENV,
|
||||
};
|
||||
}
|
||||
|
||||
for (const envVar of [ANTHROPIC_OAUTH_TOKEN_ENV, ANTHROPIC_API_KEY_ENV]) {
|
||||
const apiKey = await ctx.env(envVar);
|
||||
signal.throwIfAborted();
|
||||
if (apiKey) return { auth: { apiKey }, source: envVar };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function anthropicProvider(): Provider<"anthropic-messages"> {
|
||||
return createProvider({
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
auth: {
|
||||
apiKey: anthropicApiKeyAuth(),
|
||||
oauth: lazyOAuth({
|
||||
name: "Anthropic (Claude Pro/Max)",
|
||||
isSubscription: true,
|
||||
load: loadAnthropicOAuth,
|
||||
}),
|
||||
},
|
||||
models: Object.values(ANTHROPIC_MODELS),
|
||||
api: anthropicMessagesApi(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/azure-openai-responses.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const AZURE_OPENAI_RESPONSES_MODELS: ModelCatalog<typeof values, "azure-openai-responses"> =
|
||||
flattenModelCatalog("azure-openai-responses", values);
|
||||
14
packages/ai/src/providers/azure-openai-responses.ts
Normal file
14
packages/ai/src/providers/azure-openai-responses.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { azureOpenAIResponsesApi } from "../api/azure-openai-responses.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { AZURE_OPENAI_RESPONSES_MODELS } from "./azure-openai-responses.models.ts";
|
||||
|
||||
export function azureOpenAIResponsesProvider(): Provider<"azure-openai-responses"> {
|
||||
return createProvider({
|
||||
id: "azure-openai-responses",
|
||||
name: "Azure OpenAI",
|
||||
auth: { apiKey: envApiKeyAuth("Azure OpenAI API key", ["AZURE_OPENAI_API_KEY"]) },
|
||||
models: Object.values(AZURE_OPENAI_RESPONSES_MODELS),
|
||||
api: azureOpenAIResponsesApi(),
|
||||
});
|
||||
}
|
||||
8
packages/ai/src/providers/baseten.models.ts
Normal file
8
packages/ai/src/providers/baseten.models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/baseten.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const BASETEN_MODELS: ModelCatalog<typeof values, "baseten"> =
|
||||
flattenModelCatalog("baseten", values);
|
||||
15
packages/ai/src/providers/baseten.ts
Normal file
15
packages/ai/src/providers/baseten.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { BASETEN_MODELS } from "./baseten.models.ts";
|
||||
|
||||
export function basetenProvider(): Provider<"openai-completions"> {
|
||||
return createProvider({
|
||||
id: "baseten",
|
||||
name: "Baseten",
|
||||
baseUrl: "https://inference.baseten.co/v1",
|
||||
auth: { apiKey: envApiKeyAuth("Baseten API key", ["BASETEN_API_KEY"]) },
|
||||
models: Object.values(BASETEN_MODELS),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
}
|
||||
8
packages/ai/src/providers/cerebras.models.ts
Normal file
8
packages/ai/src/providers/cerebras.models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/cerebras.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const CEREBRAS_MODELS: ModelCatalog<typeof values, "cerebras"> =
|
||||
flattenModelCatalog("cerebras", values);
|
||||
15
packages/ai/src/providers/cerebras.ts
Normal file
15
packages/ai/src/providers/cerebras.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { CEREBRAS_MODELS } from "./cerebras.models.ts";
|
||||
|
||||
export function cerebrasProvider(): Provider<"openai-completions"> {
|
||||
return createProvider({
|
||||
id: "cerebras",
|
||||
name: "Cerebras",
|
||||
baseUrl: "https://api.cerebras.ai/v1",
|
||||
auth: { apiKey: envApiKeyAuth("Cerebras API key", ["CEREBRAS_API_KEY"]) },
|
||||
models: Object.values(CEREBRAS_MODELS),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/cloudflare-ai-gateway.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const CLOUDFLARE_AI_GATEWAY_MODELS: ModelCatalog<typeof values, "cloudflare-ai-gateway"> =
|
||||
flattenModelCatalog("cloudflare-ai-gateway", values);
|
||||
27
packages/ai/src/providers/cloudflare-ai-gateway.ts
Normal file
27
packages/ai/src/providers/cloudflare-ai-gateway.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { anthropicMessagesApi } from "../api/anthropic-messages.lazy.ts";
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { CLOUDFLARE_AI_GATEWAY_MODELS } from "./cloudflare-ai-gateway.models.ts";
|
||||
import { cloudflareAIGatewayAuth } from "./cloudflare-auth.ts";
|
||||
import { cloudflareStreams } from "./cloudflare-stream.ts";
|
||||
|
||||
type CloudflareAIGatewayApi = "anthropic-messages" | "openai-completions" | "openai-responses";
|
||||
|
||||
export function cloudflareAIGatewayProvider(): Provider<CloudflareAIGatewayApi> {
|
||||
// The api map is pinned to all three APIs: models.dev's gateway catalog drops and
|
||||
// restores `workers-ai/*` (openai-completions) entries over time, and inference from
|
||||
// `models` alone would otherwise reject the openai-completions entry whenever the
|
||||
// generated catalog happens to contain none.
|
||||
return createProvider<CloudflareAIGatewayApi>({
|
||||
id: "cloudflare-ai-gateway",
|
||||
name: "Cloudflare AI Gateway",
|
||||
auth: { apiKey: cloudflareAIGatewayAuth() },
|
||||
models: Object.values(CLOUDFLARE_AI_GATEWAY_MODELS),
|
||||
api: {
|
||||
"anthropic-messages": cloudflareStreams(anthropicMessagesApi()),
|
||||
"openai-completions": cloudflareStreams(openAICompletionsApi()),
|
||||
"openai-responses": cloudflareStreams(openAIResponsesApi()),
|
||||
},
|
||||
});
|
||||
}
|
||||
103
packages/ai/src/providers/cloudflare-auth.ts
Normal file
103
packages/ai/src/providers/cloudflare-auth.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ApiKeyAuth, ApiKeyCredential, AuthContext } from "../auth/types.ts";
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
|
||||
const CLOUDFLARE_API_KEY = "CLOUDFLARE_API_KEY";
|
||||
const CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID";
|
||||
const CLOUDFLARE_GATEWAY_ID = "CLOUDFLARE_GATEWAY_ID";
|
||||
|
||||
type CloudflareAuthKind = "workers-ai" | "ai-gateway";
|
||||
|
||||
async function resolveValue(
|
||||
name: string,
|
||||
ctx: AuthContext,
|
||||
credential: ApiKeyCredential | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<string | undefined> {
|
||||
// Per-field merge: prefer the credential value, fall back to ambient env.
|
||||
// A credential carrying only the API key must still pick up the account /
|
||||
// gateway id from the environment.
|
||||
const fromCredential = credential
|
||||
? name === CLOUDFLARE_API_KEY
|
||||
? credential.key
|
||||
: credential.env?.[name]
|
||||
: undefined;
|
||||
if (fromCredential !== undefined) return fromCredential;
|
||||
signal.throwIfAborted();
|
||||
const value = await ctx.env(name);
|
||||
signal.throwIfAborted();
|
||||
return value;
|
||||
}
|
||||
|
||||
async function resolveCloudflareEnv(
|
||||
kind: CloudflareAuthKind,
|
||||
ctx: AuthContext,
|
||||
credential: ApiKeyCredential | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ apiKey: string; env: ProviderEnv; source: string } | undefined> {
|
||||
const apiKey = await resolveValue(CLOUDFLARE_API_KEY, ctx, credential, signal);
|
||||
const accountId = await resolveValue(CLOUDFLARE_ACCOUNT_ID, ctx, credential, signal);
|
||||
const gatewayId =
|
||||
kind === "ai-gateway" ? await resolveValue(CLOUDFLARE_GATEWAY_ID, ctx, credential, signal) : undefined;
|
||||
|
||||
if (!apiKey || !accountId || (kind === "ai-gateway" && !gatewayId)) return undefined;
|
||||
|
||||
return {
|
||||
apiKey,
|
||||
env: {
|
||||
CLOUDFLARE_ACCOUNT_ID: accountId,
|
||||
...(gatewayId ? { CLOUDFLARE_GATEWAY_ID: gatewayId } : {}),
|
||||
},
|
||||
source: credential ? "stored credential" : CLOUDFLARE_API_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
export function cloudflareWorkersAIAuth(): ApiKeyAuth {
|
||||
return {
|
||||
name: "Cloudflare API key",
|
||||
login: async (interaction) => {
|
||||
const key = await interaction.prompt({ type: "secret", message: "Enter Cloudflare API key" });
|
||||
const accountId = await interaction.prompt({ type: "text", message: "Enter Cloudflare account ID" });
|
||||
return { type: "api_key", key, env: { CLOUDFLARE_ACCOUNT_ID: accountId } };
|
||||
},
|
||||
resolve: async ({ ctx, credential, signal }) => {
|
||||
const resolved = await resolveCloudflareEnv("workers-ai", ctx, credential, signal);
|
||||
if (!resolved) return undefined;
|
||||
return {
|
||||
auth: { apiKey: resolved.apiKey },
|
||||
env: resolved.env,
|
||||
source: resolved.source,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function cloudflareAIGatewayAuth(): ApiKeyAuth {
|
||||
return {
|
||||
name: "Cloudflare API key",
|
||||
login: async (interaction) => {
|
||||
const key = await interaction.prompt({ type: "secret", message: "Enter Cloudflare API key" });
|
||||
const accountId = await interaction.prompt({ type: "text", message: "Enter Cloudflare account ID" });
|
||||
const gatewayId = await interaction.prompt({ type: "text", message: "Enter Cloudflare AI Gateway ID" });
|
||||
return {
|
||||
type: "api_key",
|
||||
key,
|
||||
env: { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_GATEWAY_ID: gatewayId },
|
||||
};
|
||||
},
|
||||
resolve: async ({ ctx, credential, signal }) => {
|
||||
const resolved = await resolveCloudflareEnv("ai-gateway", ctx, credential, signal);
|
||||
if (!resolved) return undefined;
|
||||
return {
|
||||
auth: {
|
||||
headers: {
|
||||
"cf-aig-authorization": `Bearer ${resolved.apiKey}`,
|
||||
Authorization: null,
|
||||
"x-api-key": null,
|
||||
},
|
||||
},
|
||||
env: resolved.env,
|
||||
source: resolved.source,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
28
packages/ai/src/providers/cloudflare-stream.ts
Normal file
28
packages/ai/src/providers/cloudflare-stream.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Api, Model, ProviderEnv, ProviderStreams } from "../types.ts";
|
||||
|
||||
const CLOUDFLARE_ACCOUNT_ID = "CLOUDFLARE_ACCOUNT_ID";
|
||||
const CLOUDFLARE_GATEWAY_ID = "CLOUDFLARE_GATEWAY_ID";
|
||||
|
||||
export function resolveCloudflareModel<TApi extends Api>(
|
||||
model: Model<TApi>,
|
||||
env: ProviderEnv | undefined,
|
||||
): Model<TApi> {
|
||||
if (!env) return model;
|
||||
const baseUrl = model.baseUrl
|
||||
.replaceAll(`{${CLOUDFLARE_ACCOUNT_ID}}`, env[CLOUDFLARE_ACCOUNT_ID] ?? `{${CLOUDFLARE_ACCOUNT_ID}}`)
|
||||
.replaceAll(`{${CLOUDFLARE_GATEWAY_ID}}`, env[CLOUDFLARE_GATEWAY_ID] ?? `{${CLOUDFLARE_GATEWAY_ID}}`);
|
||||
return baseUrl === model.baseUrl ? model : { ...model, baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an API implementation so Cloudflare account/gateway endpoint
|
||||
* placeholders materialize from the resolved provider env before dispatch.
|
||||
*/
|
||||
export function cloudflareStreams(streams: ProviderStreams): ProviderStreams {
|
||||
return {
|
||||
stream: (model, context, options) =>
|
||||
streams.stream(resolveCloudflareModel(model, options?.env), context, options),
|
||||
streamSimple: (model, context, options) =>
|
||||
streams.streamSimple(resolveCloudflareModel(model, options?.env), context, options),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/cloudflare-workers-ai.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const CLOUDFLARE_WORKERS_AI_MODELS: ModelCatalog<typeof values, "cloudflare-workers-ai"> =
|
||||
flattenModelCatalog("cloudflare-workers-ai", values);
|
||||
15
packages/ai/src/providers/cloudflare-workers-ai.ts
Normal file
15
packages/ai/src/providers/cloudflare-workers-ai.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { cloudflareWorkersAIAuth } from "./cloudflare-auth.ts";
|
||||
import { cloudflareStreams } from "./cloudflare-stream.ts";
|
||||
import { CLOUDFLARE_WORKERS_AI_MODELS } from "./cloudflare-workers-ai.models.ts";
|
||||
|
||||
export function cloudflareWorkersAIProvider(): Provider<"openai-completions"> {
|
||||
return createProvider({
|
||||
id: "cloudflare-workers-ai",
|
||||
name: "Cloudflare Workers AI",
|
||||
auth: { apiKey: cloudflareWorkersAIAuth() },
|
||||
models: Object.values(CLOUDFLARE_WORKERS_AI_MODELS),
|
||||
api: cloudflareStreams(openAICompletionsApi()),
|
||||
});
|
||||
}
|
||||
4
packages/ai/src/providers/data-json.d.ts
vendored
Normal file
4
packages/ai/src/providers/data-json.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "*.json" {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
}
|
||||
8
packages/ai/src/providers/deepseek.models.ts
Normal file
8
packages/ai/src/providers/deepseek.models.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// This file is auto-generated by scripts/generate-models.ts
|
||||
// Do not edit manually - run 'npm run generate-models' to update
|
||||
|
||||
import values from "./data/deepseek.json" with { type: "json" };
|
||||
import { flattenModelCatalog, type ModelCatalog } from "../model-catalog.ts";
|
||||
|
||||
export const DEEPSEEK_MODELS: ModelCatalog<typeof values, "deepseek"> =
|
||||
flattenModelCatalog("deepseek", values);
|
||||
15
packages/ai/src/providers/deepseek.ts
Normal file
15
packages/ai/src/providers/deepseek.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { openAICompletionsApi } from "../api/openai-completions.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { DEEPSEEK_MODELS } from "./deepseek.models.ts";
|
||||
|
||||
export function deepseekProvider(): Provider<"openai-completions"> {
|
||||
return createProvider({
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
auth: { apiKey: envApiKeyAuth("DeepSeek API key", ["DEEPSEEK_API_KEY"]) },
|
||||
models: Object.values(DEEPSEEK_MODELS),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user