From ea8a7c759e538cc6afca5c45eb887fe7d3ea2e57 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 14:52:49 +0500 Subject: [PATCH 1/7] docs(built-in-agent): mention aimlapi.com in OpenAI-compatible providers list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aimlapi.com exposes an OpenAI-compatible route at https://api.aimlapi.com/v1, so it already works through the createOpenAI({ baseURL }) pattern this page documents for OpenRouter, Ollama, Together, Groq and Novita. Nothing in the runtime has to change for it; readers just have no way to discover that today. The callout exists because the failure it prevents is silent-looking and model-dependent, which is worse than Novita's uniform one: the gateway does serve Responses, but only for the openai/ ids it routes. The bare provider("model") call form in the adjacent OpenRouter example therefore works for openai/gpt-4o-mini and openai/gpt-4.1 and returns 404 Model not found for google/, deepseek/ and the Anthropic ids — so a reader who copies the example and swaps in a non-OpenAI model gets an error that looks like a bad model id rather than a wrong call form. Verified by driving BuiltInAgent against the endpoint on both call forms: chat form succeeded on openai/gpt-4o-mini, openai/gpt-4.1, google/gemini-2.5-flash and deepseek/deepseek-v4-flash; the bare form succeeded on the two openai/ ids and returned 404 on the other two. --- .../integrations/built-in-agent/model-selection.mdx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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..a37776cbae9 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 @@ -143,7 +143,7 @@ const agent = new BuiltInAgent({ 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 +[Groq](https://groq.com), [Novita](https://novita.ai), [aimlapi.com](https://aimlapi.com), 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] From 1052a86016dc8e1576e379041b84a29aa731612e Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 14:53:57 +0500 Subject: [PATCH 2/7] =?UTF-8?q?chore(aimlapi):=20fork-only=20partner-id=20?= =?UTF-8?q?placeholder=20=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CopilotKit does open the connection to api.aimlapi.com itself, so unlike a catalog or a pure docs entry there is a real request path that could carry partner attribution. No partner id has been registered for CopilotKit yet, so the constant is empty on purpose rather than filled with a plausible-looking value: the gateway accepts a request carrying a malformed id and drops the attribution silently, so a bad value would never fail visibly at runtime and the traffic would simply earn nothing. The test is what makes that failure mode visible, so it asserts empty-or-well-formed instead of non-empty and pins the shapes a hand-written id gets wrong. Nothing imports the constant and nothing should until an id exists. Actually sending the headers means scoping them to our origin, which in this codebase means a hardcoded aimlapi base URL inside resolveModel — the change upstream declined in CopilotKit/CopilotKit#6584. Drop this commit before any upstream PR; it is scaffolding for a colleague, not a contribution. --- .../__tests__/aimlapi-attribution.test.ts | 32 +++++++++++++++++++ .../runtime/src/agent/aimlapi-attribution.ts | 32 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts create mode 100644 packages/runtime/src/agent/aimlapi-attribution.ts 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..825ff1adf46 --- /dev/null +++ b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { + AIMLAPI_PARTNER_ID, + AIMLAPI_PARTNER_ID_PATTERN, +} from "../aimlapi-attribution"; + +describe("aimlapi partner id placeholder", () => { + it("is either empty (unregistered) or a well-formed partner id", () => { + expect( + AIMLAPI_PARTNER_ID === "" || + 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); + } + }); + + it("accepts a readable id of the shape other integrations registered", () => { + expect(AIMLAPI_PARTNER_ID_PATTERN.test("part_copilotkit")).toBe(true); + }); +}); diff --git a/packages/runtime/src/agent/aimlapi-attribution.ts b/packages/runtime/src/agent/aimlapi-attribution.ts new file mode 100644 index 00000000000..51db17b90e7 --- /dev/null +++ b/packages/runtime/src/agent/aimlapi-attribution.ts @@ -0,0 +1,32 @@ +/** + * 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. + * + * No partner id has been registered for CopilotKit, so this constant is + * deliberately EMPTY. An invented value would be worse than none: the gateway + * accepts the request either way and silently drops a malformed id, so a typo + * never surfaces at runtime and the traffic simply earns nothing. The + * accompanying test is the only thing that can catch that, which is why it + * asserts empty-or-well-formed rather than merely non-empty. + * + * Nothing imports this, and nothing should until an id exists. Sending the + * headers would mean scoping them to our origin, which in this codebase means + * a hardcoded aimlapi base URL inside `resolveModel` — precisely the change + * upstream declined in CopilotKit/CopilotKit#6584 ("It also sets a precedent + * we'd have to apply evenhandedly to every gateway that asks, which isn't a + * list we want inside `resolveModel`"). + */ +export const AIMLAPI_PARTNER_ID = ""; + +/** + * 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}$/; From c054e475a887158651d4d5c6349abd2d0a145a07 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 14:54:18 +0500 Subject: [PATCH 3/7] =?UTF-8?q?chore(aimlapi):=20fork-only=20placement=20?= =?UTF-8?q?=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI-compatible provider list on this page is hand-ordered prose, not a generated or alphabetical list, so position is a choice someone makes rather than something the tooling decides. This moves aimlapi.com from the end of that list, where the preceding commit appended it the way Novita was added, to the front. That is a partnership-placement decision, not a documentation improvement, and the page reads exactly as well either way — so it is isolated here for a colleague to drop before any upstream PR. The docs page has no "Recommended"/featured badge mechanism, so none was invented; ordering is the only lever the page offers. --- .../docs/integrations/built-in-agent/model-selection.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 a37776cbae9..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), [aimlapi.com](https://aimlapi.com), 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: From ade19b0073105603a3f12ff9ae7967b29eff51cb Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:09:35 +0500 Subject: [PATCH 4/7] fix(aimlapi): use the registered partner id The placeholder part_copilotkit was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is part_B5Xmawp87YODJfuBUtiCbR2m. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- .../runtime/src/agent/__tests__/aimlapi-attribution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts index 825ff1adf46..da0d12bdca5 100644 --- a/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts +++ b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts @@ -27,6 +27,6 @@ describe("aimlapi partner id placeholder", () => { }); it("accepts a readable id of the shape other integrations registered", () => { - expect(AIMLAPI_PARTNER_ID_PATTERN.test("part_copilotkit")).toBe(true); + expect(AIMLAPI_PARTNER_ID_PATTERN.test("part_B5Xmawp87YODJfuBUtiCbR2m")).toBe(true); }); }); From 9bd224dd819c0647998364e5ccd075ed2acf2755 Mon Sep 17 00:00:00 2001 From: Stan Date: Thu, 3 Sep 2026 18:20:14 +0500 Subject: [PATCH 5/7] fix(aimlapi): use the registered partner id The placeholder export const AIMLAPI_PARTNER_ID = ""; was a readable stand-in chosen before the partner was registered. Registration mints the id server-side, so the real value is export const AIMLAPI_PARTNER_ID = "part_B5Xmawp87YODJfuBUtiCbR2m";. A wrong or unknown partner id is accepted with a 200 and silently not attributed, so this would not have surfaced at runtime. --- packages/runtime/src/agent/aimlapi-attribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/agent/aimlapi-attribution.ts b/packages/runtime/src/agent/aimlapi-attribution.ts index 51db17b90e7..b0c0251fc6b 100644 --- a/packages/runtime/src/agent/aimlapi-attribution.ts +++ b/packages/runtime/src/agent/aimlapi-attribution.ts @@ -23,7 +23,7 @@ * we'd have to apply evenhandedly to every gateway that asks, which isn't a * list we want inside `resolveModel`"). */ -export const AIMLAPI_PARTNER_ID = ""; +export const AIMLAPI_PARTNER_ID = "part_B5Xmawp87YODJfuBUtiCbR2m"; /** * Gateway contract for the `X-AIMLAPI-Partner-ID` header: the literal prefix From f0ea94db288782f22a72686c30cc40cc1292bb8f Mon Sep 17 00:00:00 2001 From: aimlapi Date: Tue, 8 Sep 2026 07:25:57 +0500 Subject: [PATCH 6/7] feat(runtime): let BuiltInAgent send extra provider headers resolveModel() and BuiltInAgentClassicConfig gain an optional `headers` map, threaded into the provider factories for openai, anthropic, google and minimax. An endpoint reached through OPENAI_BASE_URL (a gateway, a proxy, a self-hosted server) often needs headers of its own for identification, routing or tracing, and today there is no way to supply them without constructing a LanguageModel by hand and losing the model string API. No provider is named and no endpoint is special-cased: the caller decides what to send and to which base URL. Ignored when `model` is already a LanguageModel, since that instance owns its transport. --- packages/runtime/src/agent/index.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/agent/index.ts b/packages/runtime/src/agent/index.ts index 8dd38ff802b..edb6da411f0 100644 --- a/packages/runtime/src/agent/index.ts +++ b/packages/runtime/src/agent/index.ts @@ -171,11 +171,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 +215,7 @@ export function resolveModel( // Use provided apiKey, or fall back to environment variable const openai = createOpenAI({ apiKey: apiKey || process.env.OPENAI_API_KEY!, + 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 +231,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 +246,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 +256,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 +847,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 +1050,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 +1200,7 @@ export class BuiltInAgent extends AbstractAgent { streamTextParams.model = resolveModel( props.model as string | LanguageModel, config.apiKey, + config.headers, ); } } From 80fb076dab1e73ee8db395fddbe5d7ba84371cf1 Mon Sep 17 00:00:00 2001 From: aimlapi Date: Tue, 8 Sep 2026 07:28:03 +0500 Subject: [PATCH 7/7] =?UTF-8?q?chore(aimlapi):=20attach=20attribution=20fo?= =?UTF-8?q?r=20our=20own=20origin=20=E2=80=94=20do=20not=20send=20upstream?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork-only. Fills the generic `headers` option from the previous commit with the four attribution headers, and only when OPENAI_BASE_URL names exactly https://api.aimlapi.com. The scheme is part of the check, not just the host: a name resolving to http://api.aimlapi.com:1234 inside a container's DNS would otherwise be handed both the partner id and the Authorization bearer in the clear. A suffix look-alike such as https://api.aimlapi.com.example.net is rejected too, since the comparison is on the parsed origin. The module previously said no id was registered and that nothing imported it; both statements were stale. The test no longer asserts 'empty or well-formed' — that stayed green whether the id was present or had silently reverted to empty, which is the regression worth catching. It now asserts the headers are actually attached, that they are attached nowhere else, and that a caller's own header wins on a clash. --- .../__tests__/aimlapi-attribution.test.ts | 69 +++++++++++++++--- .../runtime/src/agent/aimlapi-attribution.ts | 70 +++++++++++++++---- packages/runtime/src/agent/index.ts | 5 +- 3 files changed, 121 insertions(+), 23 deletions(-) diff --git a/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts index da0d12bdca5..258919a91d7 100644 --- a/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts +++ b/packages/runtime/src/agent/__tests__/aimlapi-attribution.test.ts @@ -1,15 +1,18 @@ 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 placeholder", () => { - it("is either empty (unregistered) or a well-formed partner id", () => { - expect( - AIMLAPI_PARTNER_ID === "" || - AIMLAPI_PARTNER_ID_PATTERN.test(AIMLAPI_PARTNER_ID), - ).toBe(true); +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", () => { @@ -25,8 +28,58 @@ describe("aimlapi partner id placeholder", () => { 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("accepts a readable id of the shape other integrations registered", () => { - expect(AIMLAPI_PARTNER_ID_PATTERN.test("part_B5Xmawp87YODJfuBUtiCbR2m")).toBe(true); + 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 index b0c0251fc6b..8f1d7dbcf8b 100644 --- a/packages/runtime/src/agent/aimlapi-attribution.ts +++ b/packages/runtime/src/agent/aimlapi-attribution.ts @@ -9,24 +9,66 @@ * does exist here, unlike a catalog or a purely documentation-level * integration. * - * No partner id has been registered for CopilotKit, so this constant is - * deliberately EMPTY. An invented value would be worse than none: the gateway - * accepts the request either way and silently drops a malformed id, so a typo - * never surfaces at runtime and the traffic simply earns nothing. The - * accompanying test is the only thing that can catch that, which is why it - * asserts empty-or-well-formed rather than merely non-empty. - * - * Nothing imports this, and nothing should until an id exists. Sending the - * headers would mean scoping them to our origin, which in this codebase means - * a hardcoded aimlapi base URL inside `resolveModel` — precisely the change - * upstream declined in CopilotKit/CopilotKit#6584 ("It also sets a precedent - * we'd have to apply evenhandedly to every gateway that asks, which isn't a - * list we want inside `resolveModel`"). + * 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. */ -export const AIMLAPI_PARTNER_ID = "part_B5Xmawp87YODJfuBUtiCbR2m"; /** * 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 edb6da411f0..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 @@ -215,7 +216,9 @@ export function resolveModel( // Use provided apiKey, or fall back to environment variable const openai = createOpenAI({ apiKey: apiKey || process.env.OPENAI_API_KEY!, - headers, + // 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