Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
460 changes: 230 additions & 230 deletions packages/ai-config/providers.schema.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions packages/ai-config/src/__tests__/resolve-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,14 @@ describe("resolveProviderCatalog — enforced beats connection env", () => {
expect(find(catalog, "anthropic")?.connection.baseUrl).toBe("https://enforced.example.com");
});

it("maps POSIT_CONNECT_URL onto the posit-connect connection baseUrl", () => {
const catalog = resolveProviderCatalog({
sources: [source("user", { providers: {} })],
envVars: { POSIT_CONNECT_URL: "https://connect.example.com" },
});
expect(find(catalog, "posit-connect")?.connection.baseUrl).toBe("https://connect.example.com");
});

it("env beats user/default when no enforced source pins the field", () => {
const catalog = resolveProviderCatalog({
sources: [
Expand Down
36 changes: 36 additions & 0 deletions packages/ai-config/src/__tests__/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,42 @@ describe("providersConfigSchema", () => {
expect(result.success).toBe(true);
});

it.each([
["litellm", "Authorization"],
["litellm", "X-API-Key"],
["portkey", "authorization"],
["portkey", "x-api-key"],
["portkey", "X-Portkey-API-Key"],
["portkey", "x-portkey-virtual-key"],
] as const)("rejects reserved %s authentication header %s", (providerId, headerName) => {
const result = providersConfigSchema.safeParse({
providers: {
[providerId]: { customHeaders: { [headerName]: "must-not-be-a-secret-channel" } },
},
});

expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues).toEqual([
expect.objectContaining({
message: expect.stringContaining("reserved"),
path: ["providers", providerId, "customHeaders", headerName],
}),
]);
}
});

it("keeps non-secret LiteLLM and Portkey routing headers valid", () => {
const result = providersConfigSchema.safeParse({
providers: {
litellm: { customHeaders: { "x-tenant": "analytics" } },
portkey: { customHeaders: { "x-portkey-provider": "openai" } },
},
});

expect(result.success).toBe(true);
});

// --- Per-key / discriminated-union strictness ---

it("rejects a foreign connection section on a built-in provider (anthropic + aws)", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-config/src/build-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const BUILTIN_CLIENT_KIND = {
databricks: "databricks",
litellm: "litellm",
portkey: "portkey",
connect: "connect",
"posit-connect": "posit-connect",
} as const satisfies Record<BuiltinProviderId, ClientKind>;

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-config/src/connection-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ const CONNECTION_ENV_MAPPINGS: Partial<Record<BuiltinProviderId, ConnectionEnvMa
portkey: { baseUrl: "PORTKEY_BASE_URL" },
// The Connect server root URL (NOT a gateway route); integration discovery
// and the per-integration gateway routes are both derived from it.
connect: { baseUrl: "POSIT_CONNECT_URL" },
"posit-connect": { baseUrl: "POSIT_CONNECT_URL" },
};

// ---------------------------------------------------------------------------
Expand Down
47 changes: 43 additions & 4 deletions packages/ai-config/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,33 @@ const baseConnectionFields = {
models: modelsBlockSchema.optional(),
};

const GATEWAY_RESERVED_AUTH_HEADERS = {
litellm: new Set(["authorization", "x-api-key"]),
portkey: new Set(["authorization", "x-api-key", "x-portkey-api-key", "x-portkey-virtual-key"]),
} as const satisfies Partial<Record<BuiltinProviderId, ReadonlySet<string>>>;

function gatewayCustomHeadersSchema(providerId: BuiltinProviderId) {
const reserved =
providerId === "litellm"
? GATEWAY_RESERVED_AUTH_HEADERS.litellm
: providerId === "portkey"
? GATEWAY_RESERVED_AUTH_HEADERS.portkey
: undefined;
const schema = z.record(z.string(), z.string());
if (!reserved) return schema;
return schema.superRefine((headers, ctx) => {
for (const name of Object.keys(headers)) {
if (reserved.has(name.toLowerCase())) {
ctx.addIssue({
code: "custom",
message: `Authentication header "${name}" is reserved and cannot be set in customHeaders.`,
path: [name],
});
}
}
});
}

/**
* The provider-specific connection sub-sections, keyed by section name. A
* provider block carries only the sub-sections its capability map names.
Expand Down Expand Up @@ -263,8 +290,17 @@ function connectionSectionShape<S extends ConnectionSectionName>(
* the per-built-in-key schemas and the custom discriminated-union variants —
* a block accepts a sub-section only if its capability map names it.
*/
function connectionBlockSchema<S extends ConnectionSectionName>(sections: readonly S[]) {
return z.object({ ...baseConnectionFields, ...connectionSectionShape(sections) }).strict();
function connectionBlockSchema<S extends ConnectionSectionName>(
providerId: BuiltinProviderId,
sections: readonly S[],
) {
return z
.object({
...baseConnectionFields,
customHeaders: gatewayCustomHeadersSchema(providerId).optional(),
...connectionSectionShape(sections),
})
.strict();
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -298,7 +334,7 @@ const BUILTIN_CONNECTION_SECTIONS = {
databricks: ["databricks"],
litellm: [],
portkey: [],
connect: [],
"posit-connect": [],
} as const satisfies Record<BuiltinProviderId, readonly ConnectionSectionName[]>;

/**
Expand Down Expand Up @@ -443,7 +479,10 @@ export const customProviderEntryFragmentSchema = z
* so adding a built-in id cannot make those paths disagree.
*/
export const builtinProviderBlockSchemas = Object.fromEntries(
BUILTIN_PROVIDER_IDS.map((id) => [id, connectionBlockSchema(BUILTIN_CONNECTION_SECTIONS[id])]),
BUILTIN_PROVIDER_IDS.map((id) => [
id,
connectionBlockSchema(id, BUILTIN_CONNECTION_SECTIONS[id]),
]),
) as Record<BuiltinProviderId, typeof builtinProviderBlockSchema>;

function optionalBuiltinBlock(
Expand Down
6 changes: 3 additions & 3 deletions packages/ai-config/src/vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const BUILTIN_PROVIDER_IDS = [
"databricks",
"litellm",
"portkey",
"connect",
"posit-connect",
] as const;

export type BuiltinProviderId = (typeof BUILTIN_PROVIDER_IDS)[number];
Expand Down Expand Up @@ -94,7 +94,7 @@ export const CLIENT_KIND_VALUES = [
"databricks",
"litellm",
"portkey",
"connect",
"posit-connect",
] as const;

export type ClientKind = (typeof CLIENT_KIND_VALUES)[number];
Expand All @@ -113,7 +113,7 @@ export type ClientKind = (typeof CLIENT_KIND_VALUES)[number];
* **equal**.
*
* This is a strict subset of {@link CLIENT_KIND_VALUES}. Product-bound kinds
* (`positai`, `copilot`, `databricks`, `connect`) remain excluded because
* (`positai`, `copilot`, `databricks`, `posit-connect`) remain excluded because
* their auth flows are not generic custom-provider flows.
*/
export const SUPPORTED_CUSTOM_CLIENT_KIND_VALUES = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ describe("resolveCredentialsFromEnv", () => {
["databricks", "DATABRICKS_TOKEN", "databricks-token"],
["litellm", "LITELLM_API_KEY", "litellm-key"],
["portkey", "PORTKEY_API_KEY", "portkey-key"],
["posit-connect", "CONNECT_API_KEY", "connect-key"],
] as const)("resolves the %s API key mapping", (providerId, envName, apiKey) => {
expect(resolveCredentialsFromEnv(providerId, { [envName]: apiKey })).toEqual({
type: "apikey",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const PROVIDER_ENV_MAPPINGS: Record<string, ProviderEnvMapping> = {
},
// The standard Posit Connect API-key variable (rsconnect/connectapi
// convention); pairs with ai-config's POSIT_CONNECT_URL connection var.
connect: {
"posit-connect": {
apiKey: "CONNECT_API_KEY",
},
};
6 changes: 5 additions & 1 deletion packages/ai-provider-bridge/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export {
registerAnthropicProvider,
registerCustomAnthropicProvider,
} from "./providers/anthropic-provider";
export { registerConnectProvider, shapeConnectIntegrations } from "./providers/connect-provider";
export {
fetchConnectIntegrations,
registerConnectProvider,
shapeConnectIntegrations,
} from "./providers/connect-provider";
export type {
ConnectAwsCredentialResult,
ConnectIntegration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ const AWS_GUID = "bbbb2222-2222-2222-2222-222222222222";
const ANTHROPIC_GATEWAY = `${CONNECT_URL}/__gateway__/anthropic/${ANTHROPIC_GUID}/v1`;
const BEDROCK_GATEWAY = `${CONNECT_URL}/__gateway__/bedrock/${AWS_GUID}`;
// Minted prefixes always embed the full guid; see mintIntegrationPrefix.
const ANTHROPIC_PREFIX = `connect-anthropic-prod-${ANTHROPIC_GUID}`;
const AWS_PREFIX = `connect-bedrock-team-${AWS_GUID}`;
const ANTHROPIC_PREFIX = `posit-connect-anthropic-prod-${ANTHROPIC_GUID}`;
const AWS_PREFIX = `posit-connect-bedrock-team-${AWS_GUID}`;

const INTEGRATION_RECORDS = [
{
Expand Down Expand Up @@ -170,10 +170,10 @@ describe("shapeConnectIntegrations", () => {
// Two integrations sharing a name still mint distinct prefixes — the
// guid, not the slug, is what makes a prefix unique.
expect(shaped.map((integration) => integration.idPrefix)).toEqual([
"connect-anthropic-guid-one",
"connect-anthropic-guid-two",
"connect-fallback-desc-guid-three",
"connect-anthropic-guid-four",
"posit-connect-anthropic-guid-one",
"posit-connect-anthropic-guid-two",
"posit-connect-fallback-desc-guid-three",
"posit-connect-anthropic-guid-four",
]);
});
});
Expand All @@ -187,7 +187,7 @@ describe("connect model fetcher", () => {

it("returns no models when the Connect server URL is missing", async () => {
const fetchMock = stubDiscoveryFetch();
const models = await registryWithProvider().getModelsForProvider("connect", {
const models = await registryWithProvider().getModelsForProvider("posit-connect", {
type: "apikey",
apiKey: "tok",
});
Expand All @@ -198,7 +198,7 @@ describe("connect model fetcher", () => {

it("returns no models for the wrong credential type", async () => {
stubDiscoveryFetch();
const models = await registryWithProvider().getModelsForProvider("connect", {
const models = await registryWithProvider().getModelsForProvider("posit-connect", {
type: "oauth",
accessToken: "tok",
});
Expand All @@ -211,7 +211,7 @@ describe("connect model fetcher", () => {
// Trailing slash on the configured URL must not produce double-slash requests.
const models = await registryWithProvider({
getAwsCredentials: vi.fn(),
}).getModelsForProvider("connect", {
}).getModelsForProvider("posit-connect", {
...credentials,
baseUrl: `${CONNECT_URL}/`,
});
Expand All @@ -232,7 +232,7 @@ describe("connect model fetcher", () => {
);
expect(anthropicModel).toMatchObject({
name: "Claude Sonnet 4.5 (Anthropic Prod)",
providerId: "connect",
providerId: "posit-connect",
vendor: "anthropic",
protocol: "anthropic-messages",
baseUrl: ANTHROPIC_GATEWAY,
Expand All @@ -254,7 +254,7 @@ describe("connect model fetcher", () => {
);
for (const model of bedrockModels) {
expect(model).toMatchObject({
providerId: "connect",
providerId: "posit-connect",
baseUrl: BEDROCK_GATEWAY,
supportsWebSearch: false,
protocol: "anthropic-messages",
Expand All @@ -266,7 +266,7 @@ describe("connect model fetcher", () => {

it("skips AWS-backed integrations and warns when no credential callback is provided", async () => {
stubDiscoveryFetch();
const models = await registryWithProvider().getModelsForProvider("connect", credentials);
const models = await registryWithProvider().getModelsForProvider("posit-connect", credentials);

expect(models.map((model) => model.id)).toEqual([
`${ANTHROPIC_PREFIX}/claude-sonnet-4-5-20250929`,
Expand All @@ -280,7 +280,7 @@ describe("connect model fetcher", () => {
});
const models = await registryWithProvider({
getAwsCredentials: vi.fn(),
}).getModelsForProvider("connect", credentials);
}).getModelsForProvider("posit-connect", credentials);

expect(models.map((model) => model.id)).toEqual(
CONNECT_BEDROCK_MODEL_IDS.map((id) => `${AWS_PREFIX}/${id}`),
Expand All @@ -297,7 +297,7 @@ describe("connect model fetcher", () => {
templates: () => ["anthropic", "github"],
};
const models = await registryWithProvider(callbacks).getModelsForProvider(
"connect",
"posit-connect",
credentials,
);

Expand Down Expand Up @@ -326,7 +326,7 @@ describe("connect chat routing", () => {
) {
const registry = new ProviderRegistry(logger);
registerConnectProvider(registry, logger, callbacks);
const client = registry.getClientForProvider("connect", creds);
const client = registry.getClientForProvider("posit-connect", creds);
expect(client).not.toBeNull();
return client!;
}
Expand All @@ -338,8 +338,8 @@ describe("connect chat routing", () => {
stubDiscoveryFetch();
const registry = new ProviderRegistry(logger);
registerConnectProvider(registry, logger, callbacks);
await registry.getModelsForProvider("connect", creds);
const client = registry.getClientForProvider("connect", creds);
await registry.getModelsForProvider("posit-connect", creds);
const client = registry.getClientForProvider("posit-connect", creds);
expect(client).not.toBeNull();
return client!;
}
Expand Down Expand Up @@ -501,7 +501,7 @@ describe("connect chat routing", () => {

await expect(
client.chat({
model: "connect-nonexistent/claude-sonnet-4-5-20250929",
model: "posit-connect-nonexistent/claude-sonnet-4-5-20250929",
messages: [],
cancellationToken,
}),
Expand All @@ -515,10 +515,10 @@ describe("connect chat routing", () => {
stubDiscoveryFetch();
const registry = new ProviderRegistry(logger);
registerConnectProvider(registry, logger);
await registry.getModelsForProvider("connect", credentials);
await registry.getModelsForProvider("posit-connect", credentials);

const otherSession = { ...credentials, apiKey: "tok-other-user" };
const client = registry.getClientForProvider("connect", otherSession)!;
const client = registry.getClientForProvider("posit-connect", otherSession)!;

await expect(
client.chat({
Expand All @@ -537,13 +537,13 @@ describe("connect chat routing", () => {
const firstSession = credentials;
const secondSession = { ...credentials, apiKey: "tok-other-user" };

await registry.getModelsForProvider("connect", firstSession);
await registry.getModelsForProvider("connect", secondSession);
await registry.getModelsForProvider("posit-connect", firstSession);
await registry.getModelsForProvider("posit-connect", secondSession);
// This is a model-cache hit for the first session. Routing state must
// remain available without forcing another network discovery.
await registry.getModelsForProvider("connect", firstSession);
await registry.getModelsForProvider("posit-connect", firstSession);

const firstClient = registry.getClientForProvider("connect", firstSession)!;
const firstClient = registry.getClientForProvider("posit-connect", firstSession)!;
await firstClient.chat({
model: `${ANTHROPIC_PREFIX}/claude-3-haiku-20240307`,
messages: [],
Expand All @@ -562,8 +562,8 @@ describe("connect chat routing", () => {
stubDiscoveryFetch();
const registry = new ProviderRegistry(logger);
registerConnectProvider(registry, logger);
await registry.getModelsForProvider("connect", credentials);
const client = registry.getClientForProvider("connect", credentials)!;
await registry.getModelsForProvider("posit-connect", credentials);
const client = registry.getClientForProvider("posit-connect", credentials)!;

await client.chat({
model: `${ANTHROPIC_PREFIX}/claude-3-haiku-20240307`,
Expand All @@ -575,8 +575,8 @@ describe("connect chat routing", () => {
stubDiscoveryFetch({
[`${CONNECT_URL}/__api__/v1/oauth/integrations`]: () => json([]),
});
registry.clearModelCache("connect");
await registry.getModelsForProvider("connect", credentials);
registry.clearModelCache("posit-connect");
await registry.getModelsForProvider("posit-connect", credentials);

await expect(
client.chat({
Expand Down Expand Up @@ -671,7 +671,7 @@ describe("connect chat routing", () => {
expect(BedrockClient).toHaveBeenCalledWith(expect.objectContaining({ customHeaders }), logger);
});

it("leaves ids whose first segment is not a connect- prefix unsplit", async () => {
it("leaves ids whose first segment is not a posit-connect- prefix unsplit", async () => {
const arn =
"arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-sonnet-4-5-20250929-v1:0";
const client = await clientAfterDiscovery({ getAwsCredentials: mintSuccess() });
Expand Down
Loading