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
14 changes: 8 additions & 6 deletions memory-bank/aiConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ enablement in Posit Assistant lives in the main monorepo's
The package has three entrypoints, splitting pure (browser/test-safe) logic from
filesystem I/O and vscode-bound wiring:

| Entrypoint | What it exports | External deps? |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `ai-config` | Vocabulary, Zod schemas, inferred types, defaults, the pure resolution helpers (`resolveModels`, `mergeEnforced`), and the config-source contracts | No |
| `ai-config/node` | Re-exports the pure entry plus the three filesystem seams (`loadResolvedProviderCatalog`, `mutateProvidersConfig`, `watchResolvedProviderCatalog`) and path constants | Node FS |
| `ai-config/positron` | Builds a `host`-kind config source from Positron's `authentication.*` VS Code settings (injected via `additionalSources`); the pure `buildAuthenticationFragment` builder is testable without vscode | `vscode` |
| `ai-config/providers.schema.json` | The generated JSON Schema, exported so editors can validate/autocomplete `providers.json` | No |
| Entrypoint | What it exports | External deps? |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `ai-config` | Vocabulary, Zod schemas, inferred types, defaults, the pure resolution helpers (`resolveModels`, `mergeEnforced`), and the config-source contracts | No |
| `ai-config/node` | Re-exports the pure entry plus the three filesystem seams (`loadResolvedProviderCatalog`, `mutateProvidersConfig`, `watchResolvedProviderCatalog`) and path constants | Node FS |
| `ai-config/positron` | Builds a `host`-kind config source from Positron's `authentication.*` VS Code settings (injected via `additionalSources`); the pure `buildAuthenticationFragment` builder is testable without vscode | `vscode` |

Each provider's `PositronAuthSettingDescriptor` (consumed by `buildAuthenticationFragment`) may carry an optional `normalizeBaseUrl?: (url: string) => string` hook, applied to the raw `baseUrl` setting before it enters the fragment. It's the seam for correcting known-bad values (e.g. a bare API host missing its version segment) without `ai-config` importing `ai-provider-bridge` — the consumer (`packages/positron`) injects the bridge's `normalizeBaseUrlForProvider` when building descriptors; `ai-config` only ever sees an opaque string-to-string function.
| `ai-config/providers.schema.json` | The generated JSON Schema, exported so editors can validate/autocomplete `providers.json` | No |

### Pure entry (`ai-config`)

Expand Down
6 changes: 6 additions & 0 deletions memory-bank/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ Positron's direct providers are the union of those two sets. Currently:
- Mapped auth providers: `anthropic`, `positai`, `openai`, `gemini`, `google-vertex`, `openai-compatible`, `bedrock`, `ms-foundry`, `snowflake-cortex`, `copilot`, `deepseek`
- Local providers: `ollama`, `lmstudio`

## Base URLs

Model clients (`AnthropicClient`, `OpenAIClient`, `GeminiClient`) **trust the base URL they are given** — `params.baseUrl ?? this.baseURL`, used raw, with no chat-time correction. There used to be a `normalizeConfiguredBaseUrl` workaround inside the clients that patched a bare host (e.g. `https://api.anthropic.com`) to its versioned form at request time; that has been removed in favor of fixing the value once, upstream, at the config-read seam.

The correction policy itself lives in one public helper, `normalizeBaseUrlForProvider(providerId, url)` (`src/base-url.ts`, exported from the root entrypoint): it corrects a bare known host (anthropic/openai/gemini, tolerant of whitespace and trailing slashes) to `host/version`, and returns **any other input byte-for-byte unchanged** — so `result !== url` means precisely "bare-host fix applied." Consumers (Positron's `authentication-source.ts` and `fix-base-url-settings.ts`) use that identity check as their write-back/notification criterion. `normalizeProviderBaseUrl` (used by the model-discovery fetchers) is unrelated: it only fills in a host/version when the URL is unset and trims whitespace/trailing slashes for composition — it does not correct a configured bare host.

## Credentials

`ProviderCredentials` is a discriminated union (`apikey`, `oauth`, `local`, `aws-credentials`, `google-cloud`) produced by `CredentialProvider` implementations. Client factories receive the resolved credential object and use it to authenticate every model-discovery and chat request.
Expand Down
55 changes: 55 additions & 0 deletions packages/ai-config/src/__tests__/positron-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,61 @@ describe("buildAuthenticationFragment", () => {
});
});

it("applies the descriptor's normalizeBaseUrl to a set base URL", () => {
const normalizeBaseUrl = (url: string) =>
url === "https://bare.example" ? "https://bare.example/v1" : url;
const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl };
const reader = fakeReader({ baseUrls: { anthropic: "https://bare.example" } });
const fragment = buildAuthenticationFragment(reader, [descriptor]);
expect(fragment).toEqual({
providers: { anthropic: { baseUrl: "https://bare.example/v1" } },
});
});

it("passes a value the normalizer leaves alone through unchanged", () => {
const normalizeBaseUrl = (url: string) =>
url === "https://bare.example" ? "https://bare.example/v1" : url;
const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl };
const reader = fakeReader({ baseUrls: { anthropic: "https://custom.example/anthropic" } });
const fragment = buildAuthenticationFragment(reader, [descriptor]);
expect(fragment).toEqual({
providers: { anthropic: { baseUrl: "https://custom.example/anthropic" } },
});
});

it("does not invoke the normalizer when baseUrl is unset", () => {
let calls = 0;
const normalizeBaseUrl = (url: string) => {
calls++;
return url;
};
const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl };
const fragment = buildAuthenticationFragment(fakeReader(), [descriptor]);
expect(fragment).toEqual({});
expect(calls).toBe(0);
});

it("does not invoke the normalizer when baseUrl is an empty string", () => {
let calls = 0;
const normalizeBaseUrl = (url: string) => {
calls++;
return url;
};
const descriptor: PositronAuthSettingDescriptor = { ...ANTHROPIC, normalizeBaseUrl };
const reader = fakeReader({ baseUrls: { anthropic: "" } });
const fragment = buildAuthenticationFragment(reader, [descriptor]);
expect(fragment).toEqual({});
expect(calls).toBe(0);
});

it("behaves as before for descriptors without normalizeBaseUrl", () => {
const reader = fakeReader({ baseUrls: { anthropic: "https://bare.example" } });
const fragment = buildAuthenticationFragment(reader, [ANTHROPIC]);
expect(fragment).toEqual({
providers: { anthropic: { baseUrl: "https://bare.example" } },
});
});

it("builds a multi-provider fragment, omitting providers with nothing set", () => {
const reader = fakeReader({
baseUrls: { anthropic: "https://a.example" },
Expand Down
14 changes: 12 additions & 2 deletions packages/ai-config/src/positron/authentication-fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ export interface PositronAuthSettingDescriptor {
* (+ `snowflake.customHeaders`), with `process.env` fallback for host/account.
*/
readonly read: "api-key-connection" | "aws-region" | "snowflake";
/**
* Optional correction applied to the raw `baseUrl` setting value before it
* enters the fragment (only meaningful for `"api-key-connection"` reads).
* Injected by the consumer — e.g. the bridge's `normalizeBaseUrlForProvider`,
* which fixes bare known hosts missing their API version segment — so
* ai-config stays free of provider-specific URL knowledge and of any
* `ai-provider-bridge` import.
*/
readonly normalizeBaseUrl?: (url: string) => string;
}

/**
Expand Down Expand Up @@ -111,7 +120,7 @@ function buildBlock(
): BuiltinProviderBlock | undefined {
switch (descriptor.read) {
case "api-key-connection":
return buildApiKeyBlock(reader, descriptor.configKey);
return buildApiKeyBlock(reader, descriptor.configKey, descriptor.normalizeBaseUrl);
case "aws-region":
return buildAwsRegionBlock(reader);
case "snowflake":
Expand All @@ -122,12 +131,13 @@ function buildBlock(
function buildApiKeyBlock(
reader: PositronAuthSettingReader,
configKey: string,
normalizeBaseUrl?: (url: string) => string,
): BuiltinProviderBlock | undefined {
const block: BuiltinProviderBlock = {};

const baseUrl = reader.getBaseUrl(configKey) || undefined;
if (baseUrl) {
block.baseUrl = baseUrl;
block.baseUrl = normalizeBaseUrl ? normalizeBaseUrl(baseUrl) : baseUrl;
}

const customHeaders = normalizeHeaders(reader.getCustomHeaders(configKey));
Expand Down
92 changes: 92 additions & 0 deletions packages/ai-provider-bridge/src/__tests__/base-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
*--------------------------------------------------------------------------------------------*/

import { describe, expect, it } from "vitest";

import { normalizeBaseUrlForProvider } from "../base-url";

interface KnownProviderCase {
providerId: "anthropic" | "openai" | "gemini";
host: string;
versioned: string;
}

const KNOWN_PROVIDERS: KnownProviderCase[] = [
{
providerId: "anthropic",
host: "https://api.anthropic.com",
versioned: "https://api.anthropic.com/v1",
},
{ providerId: "openai", host: "https://api.openai.com", versioned: "https://api.openai.com/v1" },
{
providerId: "gemini",
host: "https://generativelanguage.googleapis.com",
versioned: "https://generativelanguage.googleapis.com/v1beta",
},
];

describe.each(KNOWN_PROVIDERS)(
"normalizeBaseUrlForProvider ($providerId)",
({ providerId, host, versioned }) => {
it("corrects a bare host to the versioned form", () => {
expect(normalizeBaseUrlForProvider(providerId, host)).toBe(versioned);
});

it("corrects a bare host with a trailing slash", () => {
expect(normalizeBaseUrlForProvider(providerId, `${host}/`)).toBe(versioned);
});

it("corrects a bare host with surrounding whitespace", () => {
expect(normalizeBaseUrlForProvider(providerId, ` ${host} `)).toBe(versioned);
});

it("corrects a bare host with both a trailing slash and surrounding whitespace", () => {
expect(normalizeBaseUrlForProvider(providerId, ` ${host}/ `)).toBe(versioned);
});

it("leaves an already-versioned host unchanged", () => {
expect(normalizeBaseUrlForProvider(providerId, versioned)).toBe(versioned);
});
},
);

describe("normalizeBaseUrlForProvider: custom hosts", () => {
it("returns a custom host unchanged", () => {
const custom = "https://my-proxy.example/anthropic";
expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom);
});

it("returns a custom host with a trailing slash byte-for-byte unchanged", () => {
const custom = "https://my-proxy.example/anthropic/";
expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom);
});

it("returns a custom host with surrounding whitespace byte-for-byte unchanged", () => {
const custom = " https://my-proxy.example/anthropic ";
expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom);
});

it("returns an already-versioned host with surrounding whitespace byte-for-byte unchanged", () => {
const custom = " https://api.anthropic.com/v1 ";
expect(normalizeBaseUrlForProvider("anthropic", custom)).toBe(custom);
});
});

describe("normalizeBaseUrlForProvider: unknown providers", () => {
it("returns a bare host unchanged for a provider with no known-host policy (deepseek)", () => {
const url = "https://api.deepseek.com";
expect(normalizeBaseUrlForProvider("deepseek", url)).toBe(url);
});

it("returns a bare host unchanged for a provider with no known-host policy (bedrock)", () => {
const url = "https://bedrock-runtime.us-east-1.amazonaws.com";
expect(normalizeBaseUrlForProvider("bedrock", url)).toBe(url);
});
});

describe("normalizeBaseUrlForProvider: totality", () => {
it("never returns undefined; empty string in, empty string out", () => {
expect(normalizeBaseUrlForProvider("anthropic", "")).toBe("");
});
});
64 changes: 11 additions & 53 deletions packages/ai-provider-bridge/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,56 +7,9 @@ import { describe, expect, it } from "vitest";
import {
buildSnowflakeCortexUrl,
buildSnowflakeCortexUrlFromHost,
normalizeConfiguredBaseUrl,
normalizeProviderBaseUrl,
} from "../utils";

describe("normalizeConfiguredBaseUrl", () => {
const HOST = "https://api.anthropic.com";

it("returns undefined when baseUrl is undefined", () => {
expect(normalizeConfiguredBaseUrl(undefined, HOST, "v1")).toBeUndefined();
});

it("returns undefined when baseUrl is empty", () => {
expect(normalizeConfiguredBaseUrl("", HOST, "v1")).toBeUndefined();
});

it("returns undefined when baseUrl is whitespace only", () => {
expect(normalizeConfiguredBaseUrl(" ", HOST, "v1")).toBeUndefined();
});

it("appends the version segment when given the host with no version path", () => {
expect(normalizeConfiguredBaseUrl("https://api.anthropic.com", HOST, "v1")).toBe(
"https://api.anthropic.com/v1",
);
});

it("normalizes a trailing slash on a host with no version path", () => {
expect(normalizeConfiguredBaseUrl("https://api.anthropic.com/", HOST, "v1")).toBe(
"https://api.anthropic.com/v1",
);
});

it("leaves a host that already includes the version segment untouched", () => {
expect(normalizeConfiguredBaseUrl("https://api.anthropic.com/v1", HOST, "v1")).toBe(
"https://api.anthropic.com/v1",
);
});

it("trims surrounding whitespace and a trailing slash from a custom host", () => {
expect(normalizeConfiguredBaseUrl(" https://my-proxy.example/anthropic/ ", HOST, "v1")).toBe(
"https://my-proxy.example/anthropic",
);
});

it("leaves a custom proxy/gateway untouched", () => {
expect(normalizeConfiguredBaseUrl("https://my-proxy.example/anthropic", HOST, "v1")).toBe(
"https://my-proxy.example/anthropic",
);
});
});

describe("normalizeProviderBaseUrl", () => {
const HOST = "https://api.anthropic.com";

Expand All @@ -78,15 +31,15 @@ describe("normalizeProviderBaseUrl", () => {
);
});

it("appends the version segment when given the host with no version path", () => {
it("does not append the version segment to a bare host with no version path", () => {
expect(normalizeProviderBaseUrl("https://api.anthropic.com", HOST, "v1")).toBe(
"https://api.anthropic.com/v1",
"https://api.anthropic.com",
);
});

it("normalizes a trailing slash on a host with no version path", () => {
it("trims a trailing slash on a bare host without appending the version segment", () => {
expect(normalizeProviderBaseUrl("https://api.anthropic.com/", HOST, "v1")).toBe(
"https://api.anthropic.com/v1",
"https://api.anthropic.com",
);
});

Expand All @@ -108,12 +61,17 @@ describe("normalizeProviderBaseUrl", () => {
);
});

it("supports non-v1 version segments (Gemini)", () => {
it("supports non-v1 version segments (Gemini) when unset", () => {
const geminiHost = "https://generativelanguage.googleapis.com";
expect(normalizeProviderBaseUrl(geminiHost, geminiHost, "v1beta")).toBe(
expect(normalizeProviderBaseUrl(undefined, geminiHost, "v1beta")).toBe(
"https://generativelanguage.googleapis.com/v1beta",
);
});

it("leaves a bare configured Gemini host bare (no version segment appended)", () => {
const geminiHost = "https://generativelanguage.googleapis.com";
expect(normalizeProviderBaseUrl(geminiHost, geminiHost, "v1beta")).toBe(geminiHost);
});
});

describe("buildSnowflakeCortexUrl", () => {
Expand Down
67 changes: 67 additions & 0 deletions packages/ai-provider-bridge/src/base-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*---------------------------------------------------------------------------------------------
* Copyright (C) 2026 Posit Software, PBC. All rights reserved.
*--------------------------------------------------------------------------------------------*/

/**
* Bare-host base URL correction policy.
*
* `@ai-sdk/*` providers expect `baseURL` to already include the version segment
* (`/v1`, `/v1beta`) and append only the operation path, so a bare host like
* `https://api.anthropic.com` 404s. Historically Positron's `authentication.*`
* settings shipped such bare hosts as defaults; consumers (packages/positron)
* use this helper to correct those values at the config read seam and to
* rewrite the user's setting on disk. The bridge itself trusts the base URLs
* it is given — there is no chat-time normalization.
*
* This module owns the host/version constants (the client modules re-export
* them) and must stay dependency-light: it is reachable from the bridge ROOT
* entrypoint, which browser bundles import (via @assistant/core re-exports) —
* importing the client modules here would drag their Node-only dependencies
* (`crypto` via ai-sdk-helpers) into browser code.
*/

import type { ProviderId } from "./types";

/** Anthropic public API host. `@ai-sdk/anthropic` expects baseURL to include `/v1`. */
export const ANTHROPIC_HOST = "https://api.anthropic.com";
/** Version segment `@ai-sdk/anthropic` expects appended to the host. */
export const ANTHROPIC_API_VERSION = "v1";

/** OpenAI public API host. `@ai-sdk/openai` expects baseURL to include `/v1`. */
export const OPENAI_HOST = "https://api.openai.com";
/** Version segment `@ai-sdk/openai` expects appended to the host. */
export const OPENAI_API_VERSION = "v1";

/** Gemini public API host. `@ai-sdk/google` expects baseURL to include `/v1beta`. */
export const GEMINI_HOST = "https://generativelanguage.googleapis.com";
/** Version segment `@ai-sdk/google` expects appended to the host. */
export const GEMINI_API_VERSION = "v1beta";

/** Providers whose public API requires a version segment the SDK won't add. */
const KNOWN_HOSTS: Partial<Record<ProviderId, { host: string; version: string }>> = {
anthropic: { host: ANTHROPIC_HOST, version: ANTHROPIC_API_VERSION },
openai: { host: OPENAI_HOST, version: OPENAI_API_VERSION },
gemini: { host: GEMINI_HOST, version: GEMINI_API_VERSION },
};

/**
* Correct a bare known-provider host to its versioned form; return anything
* else unchanged.
*
* Matching is tolerant: the input is compared after trimming whitespace and
* trailing slashes, so `"https://api.anthropic.com/"` still corrects to
* `"https://api.anthropic.com/v1"`. But a non-matching input is returned
* **byte-for-byte** — no whitespace or trailing-slash cleanup — so
* `result !== url` means precisely "bare-host fix applied". Callers use that
* identity check directly as the write-back / notification criterion.
*/
export function normalizeBaseUrlForProvider(providerId: ProviderId, url: string): string {
const known = KNOWN_HOSTS[providerId];
if (!known) return url;

const candidate = url.trim().replace(/\/+$/, "");
if (candidate === known.host) {
return `${known.host}/${known.version}`;
}
return url;
}
Loading