diff --git a/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts new file mode 100644 index 00000000000..258919a91d7 --- /dev/null +++ b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { + AIMLAPI_ATTRIBUTION_HEADERS, + AIMLAPI_PARTNER_ID, + AIMLAPI_PARTNER_ID_PATTERN, + isAimlapiOrigin, + withAimlapiAttribution, +} from "../aimlapi-attribution"; + +describe("aimlapi partner id", () => { + it("is a well-formed, registered id", () => { + // Deliberately not "empty or well-formed": that shape stayed green whether + // the id was present or had silently reverted to "", which is exactly the + // regression worth catching now that a real id exists. + expect(AIMLAPI_PARTNER_ID_PATTERN.test(AIMLAPI_PARTNER_ID)).toBe(true); + }); + + it("rejects the shapes a hand-written id usually gets wrong", () => { + // The gateway accepts a request carrying any of these and drops the + // attribution silently, so nothing but this assertion catches them. + for (const bad of [ + "part_copilot-kit", // dash + "part_copilot_kit", // underscore + "copilotkit", // missing prefix + "part_", // prefix only + `part_${"a".repeat(65)}`, // over 64 characters + ]) { + expect(AIMLAPI_PARTNER_ID_PATTERN.test(bad)).toBe(false); + } + }); +}); + +describe("origin scoping", () => { + it("accepts our own origin", () => { + expect(isAimlapiOrigin("https://api.aimlapi.com/v1")).toBe(true); + expect(isAimlapiOrigin("https://api.aimlapi.com")).toBe(true); + }); + + it("rejects everything else, including look-alikes", () => { + for (const url of [ + "http://api.aimlapi.com/v1", // plaintext would expose the bearer too + "https://api.aimlapi.com.example.net/v1", // suffix look-alike + "https://api.openai.com/v1", + "http://127.0.0.1:8814/v1", + "not a url", + undefined, + ]) { + expect(isAimlapiOrigin(url)).toBe(false); + } + }); +}); + +describe("header attachment", () => { + it("attaches all four headers for our origin", () => { + const headers = withAimlapiAttribution("https://api.aimlapi.com/v1"); + expect(headers).toMatchObject({ + "HTTP-Referer": "https://github.com/CopilotKit/CopilotKit", + "X-Title": "CopilotKit", + "X-AIMLAPI-Source": "agent/copilotkit", + "X-AIMLAPI-Partner-ID": AIMLAPI_PARTNER_ID, + }); + }); + + it("attaches nothing anywhere else, and passes the caller's headers through", () => { + expect(withAimlapiAttribution(undefined)).toBeUndefined(); + expect(withAimlapiAttribution("https://api.openai.com/v1")).toBeUndefined(); + expect( + withAimlapiAttribution("https://api.openai.com/v1", { "X-Trace": "1" }), + ).toEqual({ "X-Trace": "1" }); + }); + + it("lets the caller's own header win on a clash", () => { + const headers = withAimlapiAttribution("https://api.aimlapi.com/v1", { + "X-Title": "My App", + }); + expect(headers?.["X-Title"]).toBe("My App"); + expect(headers?.["X-AIMLAPI-Partner-ID"]).toBe(AIMLAPI_PARTNER_ID); + }); + + it("does not let a caller mutate the shared constant", () => { + const headers = withAimlapiAttribution("https://api.aimlapi.com/v1")!; + headers["X-Title"] = "mutated"; + expect(AIMLAPI_ATTRIBUTION_HEADERS["X-Title"]).toBe("CopilotKit"); + }); +}); diff --git a/packages/runtime/src/agent/aimlapi-attribution.ts b/packages/runtime/src/agent/aimlapi-attribution.ts new file mode 100644 index 00000000000..8f1d7dbcf8b --- /dev/null +++ b/packages/runtime/src/agent/aimlapi-attribution.ts @@ -0,0 +1,74 @@ +/** + * Fork-only scaffolding — not part of the upstream contribution. + * + * The Built-in Agent reaches aimlapi.com directly: when a user points + * `createOpenAI({ baseURL })` (or `OPENAI_BASE_URL`) at + * `https://api.aimlapi.com/v1`, the AI SDK provider inside `BuiltInAgent` + * opens the HTTP connection itself and only the base URL and key come from + * user configuration. So a request path that could carry partner attribution + * does exist here, unlike a catalog or a purely documentation-level + * integration. + * + * Upstream declined to special-case a gateway inside `resolveModel` + * (CopilotKit/CopilotKit#6584: "It also sets a precedent we'd have to apply + * evenhandedly to every gateway that asks"). That objection is respected: the + * upstream-facing change is a generic `headers` option on + * `BuiltInAgentClassicConfig`, naming no provider. This module is the fork-only + * half that fills those headers in for our own endpoint, and it is expected to + * be dropped before anything is offered upstream. + */ + +/** + * Gateway contract for the `X-AIMLAPI-Partner-ID` header: the literal prefix + * `part_` followed by 1-64 alphanumerics. No dashes, no underscores. + */ +export const AIMLAPI_PARTNER_ID_PATTERN = /^part_[A-Za-z0-9]{1,64}$/; + +export const AIMLAPI_PARTNER_ID = "part_B5Xmawp87YODJfuBUtiCbR2m"; + +/** + * The origin the headers are scoped to. Both halves matter. + * + * The host half keeps our partner id from travelling to somebody else's + * gateway when a user repoints `OPENAI_BASE_URL`. The scheme half keeps the id + * — and the Authorization bearer beside it — off a plaintext connection: a + * name that resolves to `http://api.aimlapi.com:1234` inside a container's DNS + * would otherwise be handed both. + */ +const AIMLAPI_ORIGIN = "https://api.aimlapi.com"; + +export const AIMLAPI_ATTRIBUTION_HEADERS: Readonly> = + Object.freeze({ + "HTTP-Referer": "https://github.com/CopilotKit/CopilotKit", + "X-Title": "CopilotKit", + "X-AIMLAPI-Source": "agent/copilotkit", + "X-AIMLAPI-Partner-ID": AIMLAPI_PARTNER_ID, + }); + +/** + * True only when `baseURL` names our own origin — exact host, https scheme. + * + * A look-alike such as `https://api.aimlapi.com.example.net/v1` is rejected, + * because the comparison is on the parsed origin rather than a substring. + */ +export function isAimlapiOrigin(baseURL: string | undefined): boolean { + if (!baseURL) return false; + try { + return new URL(baseURL).origin === AIMLAPI_ORIGIN; + } catch { + return false; + } +} + +/** + * Merges attribution into the caller's headers when, and only when, the + * request is bound for our origin. The caller's own headers win on a clash — + * an application that deliberately sets `X-Title` keeps its value. + */ +export function withAimlapiAttribution( + baseURL: string | undefined, + headers?: Record, +): Record | undefined { + if (!isAimlapiOrigin(baseURL)) return headers; + return { ...AIMLAPI_ATTRIBUTION_HEADERS, ...(headers ?? {}) }; +} diff --git a/packages/runtime/src/agent/index.ts b/packages/runtime/src/agent/index.ts index 8dd38ff802b..69204d3c180 100644 --- a/packages/runtime/src/agent/index.ts +++ b/packages/runtime/src/agent/index.ts @@ -64,6 +64,7 @@ import { createStateEventNormalizer } from "./state-delta"; import type { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { randomUUID } from "@copilotkit/shared"; +import { withAimlapiAttribution } from "./aimlapi-attribution"; /** * Properties that can be overridden by forwardedProps @@ -171,11 +172,17 @@ export interface MCPClientProvider { * Resolves a model specifier to a LanguageModel instance * @param spec - Model string (e.g., "openai/gpt-4o") or LanguageModel instance * @param apiKey - Optional API key to use instead of environment variables + * @param headers - Optional extra HTTP headers sent with every request to the + * provider. Useful when a gateway or self-hosted endpoint needs its own + * identification, routing or tracing headers; the provider merges them into + * each outgoing request. Ignored when `spec` is already a LanguageModel, + * because that instance owns its own transport. * @returns LanguageModel instance */ export function resolveModel( spec: ModelSpecifier, apiKey?: string, + headers?: Record, ): LanguageModel { // If already a LanguageModel instance, pass through if (typeof spec !== "string") { @@ -209,6 +216,9 @@ export function resolveModel( // Use provided apiKey, or fall back to environment variable const openai = createOpenAI({ apiKey: apiKey || process.env.OPENAI_API_KEY!, + // Fork-only: attribution for aimlapi.com, attached only when the base + // URL is our own origin. Drop this line with the fork-only commit. + headers: withAimlapiAttribution(process.env.OPENAI_BASE_URL, headers), // Honor an OpenAI-COMPATIBLE endpoint (Azure OpenAI, OpenRouter, a gateway, // vLLM/LM Studio/Ollama, etc.) via the standard OPENAI_BASE_URL env var. // Undefined when unset, so the provider falls back to its default @@ -224,6 +234,7 @@ export function resolveModel( // Use provided apiKey, or fall back to environment variable const anthropic = createAnthropic({ apiKey: apiKey || process.env.ANTHROPIC_API_KEY!, + headers, // Honor a custom Anthropic-compatible endpoint via ANTHROPIC_BASE_URL (see OpenAI note). baseURL: process.env.ANTHROPIC_BASE_URL, }); @@ -238,6 +249,7 @@ export function resolveModel( // Use provided apiKey, or fall back to environment variable const google = createGoogleGenerativeAI({ apiKey: apiKey || process.env.GOOGLE_API_KEY!, + headers, // Honor a custom Google-compatible endpoint via GOOGLE_GENERATIVE_AI_BASE_URL (see OpenAI note). baseURL: process.env.GOOGLE_GENERATIVE_AI_BASE_URL, }); @@ -247,6 +259,7 @@ export function resolveModel( case "minimax": { const minimax = createOpenAI({ + headers, name: "minimax", apiKey: apiKey || process.env.MINIMAX_API_KEY!, baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1", @@ -837,6 +850,16 @@ export interface BuiltInAgentClassicConfig { * - MINIMAX_API_KEY for MiniMax models */ apiKey?: string; + /** + * Extra HTTP headers sent with every request to the model provider. + * + * Use this when the endpoint behind `OPENAI_BASE_URL` (or the equivalent for + * another provider) expects headers of its own — a gateway that identifies + * callers, a proxy that needs a routing hint, a tracing header. Ignored when + * `model` is already a LanguageModel instance, since that instance brings its + * own transport. + */ + headers?: Record; /** * Maximum number of steps/iterations for tool calling (default: 1) */ @@ -1030,7 +1053,7 @@ export class BuiltInAgent extends AbstractAgent { subscriber.next(startEvent); // Resolve the model, passing API key if provided - const model = resolveModel(config.model, config.apiKey); + const model = resolveModel(config.model, config.apiKey, config.headers); // Build prompt based on conditions let systemPrompt: string | undefined = undefined; @@ -1180,6 +1203,7 @@ export class BuiltInAgent extends AbstractAgent { streamTextParams.model = resolveModel( props.model as string | LanguageModel, config.apiKey, + config.headers, ); } } diff --git a/showcase/shell-docs/src/content/docs/integrations/built-in-agent/model-selection.mdx b/showcase/shell-docs/src/content/docs/integrations/built-in-agent/model-selection.mdx index c2cb2e24d4f..f8ded17d5e0 100644 --- a/showcase/shell-docs/src/content/docs/integrations/built-in-agent/model-selection.mdx +++ b/showcase/shell-docs/src/content/docs/integrations/built-in-agent/model-selection.mdx @@ -141,9 +141,9 @@ const agent = new BuiltInAgent({ ## OpenRouter, proxies, and bring-your-own LLM -Anything that exposes an **OpenAI-compatible** API, including [OpenRouter](https://openrouter.ai), -a self-hosted gateway, an internal LLM proxy, [Ollama](https://ollama.com), [Together](https://together.ai), -[Groq](https://groq.com), [Novita](https://novita.ai), or your own fine-tuned endpoint, works through the same +Anything that exposes an **OpenAI-compatible** API, including [aimlapi.com](https://aimlapi.com), +[OpenRouter](https://openrouter.ai), a self-hosted gateway, an internal LLM proxy, [Ollama](https://ollama.com), +[Together](https://together.ai), [Groq](https://groq.com), [Novita](https://novita.ai), or your own fine-tuned endpoint, works through the same `createOpenAI({ baseURL })` pattern shown in [Custom Models](#custom-models-ai-sdk) above. Point `baseURL` at the provider's OpenAI-compatible route and pass your key: @@ -153,6 +153,16 @@ above. Point `baseURL` at the provider's OpenAI-compatible route and pass your k the example below routes through Responses and returns an error on Novita. + + aimlapi.com serves `https://api.aimlapi.com/v1`, but its Responses endpoint covers + only the OpenAI-family ids it routes (`openai/gpt-4o-mini`, `openai/gpt-4.1`, and + the other `openai/` ids). Every other id — `google/gemini-2.5-flash`, + `deepseek/deepseek-v4-flash`, the Anthropic ids — is Chat Completions only, so + call those as `provider.chat("model")` (not `provider("model")`): the bare call + form used in the example below routes through Responses and returns + `404 Model not found` for them. + + ```typescript import { BuiltInAgent } from "@copilotkit/runtime/v2"; import { createOpenAI } from "@ai-sdk/openai"; // [!code highlight]