diff --git a/packages/ai-config/providers.schema.json b/packages/ai-config/providers.schema.json index 9b5daa3..9163746 100644 --- a/packages/ai-config/providers.schema.json +++ b/packages/ai-config/providers.schema.json @@ -482,236 +482,6 @@ }, "type": "object" }, - "connect": { - "additionalProperties": false, - "properties": { - "baseUrl": { - "type": "string" - }, - "customHeaders": { - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "type": "string" - }, - "type": "object" - }, - "enabled": { - "type": "boolean" - }, - "endpoint": { - "type": "string" - }, - "endpoints": { - "additionalProperties": { - "type": "string" - }, - "propertyNames": { - "enum": [ - "anthropic-messages", - "openai-chat", - "openai-responses", - "mlflow-responses", - "bedrock-converse", - "google-generative" - ], - "type": "string" - }, - "type": "object" - }, - "models": { - "additionalProperties": false, - "properties": { - "allow": { - "items": { - "type": "string" - }, - "type": "array" - }, - "custom": { - "items": { - "additionalProperties": false, - "properties": { - "baseUrl": { - "type": "string" - }, - "family": { - "type": "string" - }, - "id": { - "minLength": 1, - "type": "string" - }, - "maxContextLength": { - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "type": "integer" - }, - "maxInputTokens": { - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "type": "integer" - }, - "maxOutputTokens": { - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "type": "integer" - }, - "name": { - "minLength": 1, - "type": "string" - }, - "protocol": { - "enum": [ - "anthropic-messages", - "openai-chat", - "openai-responses", - "mlflow-responses", - "bedrock-converse", - "google-generative" - ], - "type": "string" - }, - "supportedInputMediaTypes": { - "items": { - "type": "string" - }, - "type": "array" - }, - "supportsImages": { - "type": "boolean" - }, - "supportsToolResultImages": { - "type": "boolean" - }, - "supportsTools": { - "type": "boolean" - }, - "supportsWebSearch": { - "type": "boolean" - }, - "thinkingEffortLevels": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "id", - "name", - "maxContextLength", - "supportsTools", - "supportsImages", - "supportsToolResultImages", - "supportsWebSearch" - ], - "type": "object" - }, - "type": "array" - }, - "deny": { - "items": { - "type": "string" - }, - "type": "array" - }, - "discovery": { - "enum": [ - "auto", - "off" - ], - "type": "string" - }, - "overrides": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "baseUrl": { - "type": "string" - }, - "family": { - "type": "string" - }, - "maxContextLength": { - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "type": "integer" - }, - "maxInputTokens": { - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "type": "integer" - }, - "maxOutputTokens": { - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "type": "integer" - }, - "name": { - "type": "string" - }, - "protocol": { - "enum": [ - "anthropic-messages", - "openai-chat", - "openai-responses", - "mlflow-responses", - "bedrock-converse", - "google-generative" - ], - "type": "string" - }, - "supportedInputMediaTypes": { - "items": { - "type": "string" - }, - "type": "array" - }, - "supportsImages": { - "type": "boolean" - }, - "supportsToolResultImages": { - "type": "boolean" - }, - "supportsTools": { - "type": "boolean" - }, - "supportsWebSearch": { - "type": "boolean" - }, - "thinkingEffortLevels": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "type": "object" - }, - "propertyNames": { - "type": "string" - }, - "type": "object" - } - }, - "type": "object" - }, - "protocol": { - "enum": [ - "anthropic-messages", - "openai-chat", - "openai-responses", - "mlflow-responses", - "bedrock-converse", - "google-generative" - ], - "type": "string" - } - }, - "type": "object" - }, "copilot": { "additionalProperties": false, "properties": { @@ -7233,6 +7003,236 @@ }, "type": "object" }, + "posit-connect": { + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "customHeaders": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + }, + "enabled": { + "type": "boolean" + }, + "endpoint": { + "type": "string" + }, + "endpoints": { + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "enum": [ + "anthropic-messages", + "openai-chat", + "openai-responses", + "mlflow-responses", + "bedrock-converse", + "google-generative" + ], + "type": "string" + }, + "type": "object" + }, + "models": { + "additionalProperties": false, + "properties": { + "allow": { + "items": { + "type": "string" + }, + "type": "array" + }, + "custom": { + "items": { + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "family": { + "type": "string" + }, + "id": { + "minLength": 1, + "type": "string" + }, + "maxContextLength": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "maxInputTokens": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "maxOutputTokens": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "name": { + "minLength": 1, + "type": "string" + }, + "protocol": { + "enum": [ + "anthropic-messages", + "openai-chat", + "openai-responses", + "mlflow-responses", + "bedrock-converse", + "google-generative" + ], + "type": "string" + }, + "supportedInputMediaTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "supportsImages": { + "type": "boolean" + }, + "supportsToolResultImages": { + "type": "boolean" + }, + "supportsTools": { + "type": "boolean" + }, + "supportsWebSearch": { + "type": "boolean" + }, + "thinkingEffortLevels": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "maxContextLength", + "supportsTools", + "supportsImages", + "supportsToolResultImages", + "supportsWebSearch" + ], + "type": "object" + }, + "type": "array" + }, + "deny": { + "items": { + "type": "string" + }, + "type": "array" + }, + "discovery": { + "enum": [ + "auto", + "off" + ], + "type": "string" + }, + "overrides": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string" + }, + "family": { + "type": "string" + }, + "maxContextLength": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "maxInputTokens": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "maxOutputTokens": { + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "enum": [ + "anthropic-messages", + "openai-chat", + "openai-responses", + "mlflow-responses", + "bedrock-converse", + "google-generative" + ], + "type": "string" + }, + "supportedInputMediaTypes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "supportsImages": { + "type": "boolean" + }, + "supportsToolResultImages": { + "type": "boolean" + }, + "supportsTools": { + "type": "boolean" + }, + "supportsWebSearch": { + "type": "boolean" + }, + "thinkingEffortLevels": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "propertyNames": { + "type": "string" + }, + "type": "object" + } + }, + "type": "object" + }, + "protocol": { + "enum": [ + "anthropic-messages", + "openai-chat", + "openai-responses", + "mlflow-responses", + "bedrock-converse", + "google-generative" + ], + "type": "string" + } + }, + "type": "object" + }, "positai": { "additionalProperties": false, "properties": { diff --git a/packages/ai-config/src/__tests__/resolve-catalog.test.ts b/packages/ai-config/src/__tests__/resolve-catalog.test.ts index 746aed6..a3ea95f 100644 --- a/packages/ai-config/src/__tests__/resolve-catalog.test.ts +++ b/packages/ai-config/src/__tests__/resolve-catalog.test.ts @@ -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: [ diff --git a/packages/ai-config/src/__tests__/schema.test.ts b/packages/ai-config/src/__tests__/schema.test.ts index aeb4596..cbe330c 100644 --- a/packages/ai-config/src/__tests__/schema.test.ts +++ b/packages/ai-config/src/__tests__/schema.test.ts @@ -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)", () => { diff --git a/packages/ai-config/src/build-catalog.ts b/packages/ai-config/src/build-catalog.ts index b04358d..c819727 100644 --- a/packages/ai-config/src/build-catalog.ts +++ b/packages/ai-config/src/build-catalog.ts @@ -58,7 +58,7 @@ const BUILTIN_CLIENT_KIND = { databricks: "databricks", litellm: "litellm", portkey: "portkey", - connect: "connect", + "posit-connect": "posit-connect", } as const satisfies Record; /** diff --git a/packages/ai-config/src/connection-env.ts b/packages/ai-config/src/connection-env.ts index 918cabd..e07f430 100644 --- a/packages/ai-config/src/connection-env.ts +++ b/packages/ai-config/src/connection-env.ts @@ -96,7 +96,7 @@ const CONNECTION_ENV_MAPPINGS: Partial>>; + +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. @@ -263,8 +290,17 @@ function connectionSectionShape( * 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(sections: readonly S[]) { - return z.object({ ...baseConnectionFields, ...connectionSectionShape(sections) }).strict(); +function connectionBlockSchema( + providerId: BuiltinProviderId, + sections: readonly S[], +) { + return z + .object({ + ...baseConnectionFields, + customHeaders: gatewayCustomHeadersSchema(providerId).optional(), + ...connectionSectionShape(sections), + }) + .strict(); } // --------------------------------------------------------------------------- @@ -298,7 +334,7 @@ const BUILTIN_CONNECTION_SECTIONS = { databricks: ["databricks"], litellm: [], portkey: [], - connect: [], + "posit-connect": [], } as const satisfies Record; /** @@ -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; function optionalBuiltinBlock( diff --git a/packages/ai-config/src/vocabulary.ts b/packages/ai-config/src/vocabulary.ts index 0082600..9639eee 100644 --- a/packages/ai-config/src/vocabulary.ts +++ b/packages/ai-config/src/vocabulary.ts @@ -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]; @@ -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]; @@ -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 = [ diff --git a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts index 6d2b31c..7b7d716 100644 --- a/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts +++ b/packages/ai-credentials/src/store-backend/__tests__/EnvCredentialResolver.test.ts @@ -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", diff --git a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts index 475bda8..7edddb8 100644 --- a/packages/ai-credentials/src/store-backend/providerEnvMappings.ts +++ b/packages/ai-credentials/src/store-backend/providerEnvMappings.ts @@ -88,7 +88,7 @@ export const PROVIDER_ENV_MAPPINGS: Record = { }, // 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", }, }; diff --git a/packages/ai-provider-bridge/src/providers.ts b/packages/ai-provider-bridge/src/providers.ts index 1ce5b08..9c0155a 100644 --- a/packages/ai-provider-bridge/src/providers.ts +++ b/packages/ai-provider-bridge/src/providers.ts @@ -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, diff --git a/packages/ai-provider-bridge/src/providers/__tests__/connect-provider.test.ts b/packages/ai-provider-bridge/src/providers/__tests__/connect-provider.test.ts index e16151b..d4e10c6 100644 --- a/packages/ai-provider-bridge/src/providers/__tests__/connect-provider.test.ts +++ b/packages/ai-provider-bridge/src/providers/__tests__/connect-provider.test.ts @@ -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 = [ { @@ -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", ]); }); }); @@ -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", }); @@ -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", }); @@ -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}/`, }); @@ -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, @@ -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", @@ -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`, @@ -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}`), @@ -297,7 +297,7 @@ describe("connect model fetcher", () => { templates: () => ["anthropic", "github"], }; const models = await registryWithProvider(callbacks).getModelsForProvider( - "connect", + "posit-connect", credentials, ); @@ -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!; } @@ -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!; } @@ -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, }), @@ -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({ @@ -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: [], @@ -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`, @@ -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({ @@ -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() }); diff --git a/packages/ai-provider-bridge/src/providers/connect-provider.ts b/packages/ai-provider-bridge/src/providers/connect-provider.ts index 8fd5859..0f5d31b 100644 --- a/packages/ai-provider-bridge/src/providers/connect-provider.ts +++ b/packages/ai-provider-bridge/src/providers/connect-provider.ts @@ -10,7 +10,7 @@ * `apikey`), and `baseUrl` is the Connect server URL. Discovery spends the * token on `GET {baseUrl}/__api__/v1/oauth/integrations`, keeps the * integrations whose `template` is allowlisted, and namespaces each - * integration's models as `connect-/` so one flat model list + * integration's models as `posit-connect-/` so one flat model list * can route back to the right gateway. * * Chat routing is stateless for discovery-stamped models: the stamped gateway @@ -73,7 +73,7 @@ const CONNECT_CACHE_MAX_ENTRIES = 32; /** One allowlisted integration, shaped from Connect's integrations endpoint. */ export interface ConnectIntegration { /** - * The model-id namespace for this integration (`connect-`); every + * The model-id namespace for this integration (`posit-connect-`); every * model it serves is listed as `/`. See * {@link mintIntegrationPrefix}. */ @@ -156,7 +156,7 @@ function slugify(input: string): string { /** * Derive a human-readable model-id prefix from the integration's admin-facing - * name (e.g. `connect-anthropic-superuser-`), falling back to + * name (e.g. `posit-connect-anthropic-superuser-`), falling back to * description, then template, when the name is blank. The guid is always * embedded, so two integrations can never mint the same prefix regardless of * record order — chat routing keys on the stamped gateway URL regardless, but @@ -169,7 +169,7 @@ function mintIntegrationPrefix(input: { guid: string; }): string { const slug = slugify(input.name) || slugify(input.description) || slugify(input.template); - return slug ? `connect-${slug}-${input.guid}` : `connect-${input.guid}`; + return slug ? `posit-connect-${slug}-${input.guid}` : `posit-connect-${input.guid}`; } /** @@ -315,31 +315,41 @@ function resolveTemplates( const unsupported = configured.filter((template) => !SUPPORTED_TEMPLATES.has(template)); if (unsupported.length > 0) { logger.warn( - `[connect] Ignoring unsupported integration template(s): ${unsupported.join(", ")}; ` + + `[posit-connect] Ignoring unsupported integration template(s): ${unsupported.join(", ")}; ` + `only ${[...SUPPORTED_TEMPLATES].join(", ")} can be shaped into models today.`, ); } return configured.filter((template) => SUPPORTED_TEMPLATES.has(template)); } -async function fetchIntegrationRecords( +/** + * Fetch the integrations visible to this API key and shape the allowlisted + * ones. Throws on HTTP failure or a non-array body. This is the canonical + * read of the integrations endpoint — discovery and host test probes both go + * through it so the path, auth convention, and validation cannot drift. + */ +export async function fetchConnectIntegrations( connectUrl: string, credentials: ApiKeyCredentials, - signal: AbortSignal, -): Promise { + templates: readonly string[], + signal?: AbortSignal, +): Promise { + const baseUrl = connectUrl.replace(/\/+$/, ""); const headers = additiveHeaderRecord( { Authorization: `Key ${credentials.apiKey}` }, credentials.customHeaders, ); - const response = await fetch(`${connectUrl}${INTEGRATIONS_PATH}`, { headers, signal }); + const response = await fetch(`${baseUrl}${INTEGRATIONS_PATH}`, { headers, signal }); if (!response.ok) { - throw new Error(`Connect integrations endpoint returned ${response.status}`); + throw new Error( + `Connect integrations endpoint returned ${response.status}: ${response.statusText}`, + ); } const body: unknown = await response.json(); if (!Array.isArray(body)) { throw new Error("Connect integrations response was not a JSON array"); } - return body; + return shapeConnectIntegrations(body, baseUrl, templates); } /** @@ -460,12 +470,16 @@ function createConnectModelFetcher( const connectUrl = credentials.baseUrl!.replace(/\/+$/, ""); const credentialKey = await connectCredentialKey(connectUrl, credentials); const templates = resolveTemplates(callbacks?.templates?.(), logger); - const records = await fetchIntegrationRecords(connectUrl, credentials, signal); - const integrations = shapeConnectIntegrations(records, connectUrl, templates); + const integrations = await fetchConnectIntegrations( + connectUrl, + credentials, + templates, + signal, + ); if (!callbacks && integrations.some((integration) => integration.template === "aws")) { logger.warn( - "[connect] Skipping AWS-backed integrations: no AWS credential callback was provided, " + + "[posit-connect] Skipping AWS-backed integrations: no AWS credential callback was provided, " + "so their gateway requests could never be signed.", ); } @@ -489,7 +503,7 @@ function createConnectModelFetcher( if (signal.aborted) throw error; const message = error instanceof Error ? error.message : String(error); logger.warn( - `[connect] Model discovery failed for integration "${integrationLabel(integration)}": ${message}`, + `[posit-connect] Model discovery failed for integration "${integrationLabel(integration)}": ${message}`, ); return []; } @@ -518,14 +532,14 @@ function createConnectModelFetcher( // --------------------------------------------------------------------------- /** - * Split a namespaced `connect-/` id on the FIRST `/` only — + * Split a namespaced `posit-connect-/` id on the FIRST `/` only — * the minted prefix never contains one, but Bedrock model ids (ARNs) may. - * A first segment that is not a minted `connect-` prefix (e.g. a raw ARN's + * A first segment that is not a minted `posit-connect-` prefix (e.g. a raw ARN's * `arn:aws:...`) leaves the whole id as the wire model. */ function splitConnectModelId(model: string): { prefix?: string; wireModel: string } { const separator = model.indexOf("/"); - if (separator <= 0 || !model.slice(0, separator).startsWith("connect-")) { + if (separator <= 0 || !model.slice(0, separator).startsWith("posit-connect-")) { return { wireModel: model }; } return { prefix: model.slice(0, separator), wireModel: model.slice(separator + 1) }; @@ -663,7 +677,7 @@ class ConnectClient implements ModelClient { const cached = this.cache.findByGuid(credentialKey, parsed.guid); if (cached) return cached; return { - idPrefix: prefix ?? `connect-${parsed.guid}`, + idPrefix: prefix ?? `posit-connect-${parsed.guid}`, guid: parsed.guid, template: parsed.template, name: "", @@ -717,7 +731,7 @@ class ConnectClient implements ModelClient { const region = integration.region ?? aws.region; if (integration.region && aws.region && integration.region !== aws.region) { this.logger.warn( - `[connect] Integration "${integrationLabel(integration)}" declares sts_region ` + + `[posit-connect] Integration "${integrationLabel(integration)}" declares sts_region ` + `${integration.region} but the minted credentials name ${aws.region}; signing with ${region}.`, ); } @@ -765,8 +779,11 @@ export function registerConnectProvider( // models route from their gateway URL alone. const cache = new ConnectIntegrationCache(); registry.registerModelFetcher( - "connect", - createConnectModelFetcher("connect", logger, cache, callbacks), + "posit-connect", + createConnectModelFetcher("posit-connect", logger, cache, callbacks), + ); + registry.registerClientFactory( + "posit-connect", + createConnectClientFactory(logger, cache, callbacks), ); - registry.registerClientFactory("connect", createConnectClientFactory(logger, cache, callbacks)); } diff --git a/packages/ai-provider-bridge/src/register-all-providers.ts b/packages/ai-provider-bridge/src/register-all-providers.ts index c4c835b..5011389 100644 --- a/packages/ai-provider-bridge/src/register-all-providers.ts +++ b/packages/ai-provider-bridge/src/register-all-providers.ts @@ -95,7 +95,7 @@ const PROVIDER_REGISTRARS = { databricks: registerDatabricksProvider, litellm: registerLitellmProvider, portkey: registerPortkeyProvider, - connect: (registry, logger, config) => + "posit-connect": (registry, logger, config) => registerConnectProvider(registry, logger, config.connectCallbacks), } satisfies Record; diff --git a/packages/ai-provider-bridge/src/types.ts b/packages/ai-provider-bridge/src/types.ts index e6d1eff..26c17b0 100644 --- a/packages/ai-provider-bridge/src/types.ts +++ b/packages/ai-provider-bridge/src/types.ts @@ -58,7 +58,7 @@ export const PROVIDER_IDS = [ "databricks", "litellm", "portkey", - "connect", + "posit-connect", ] as const; /**