Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
910315a
docs: global provider connections design (PR 1 of Seb #2061 decomposi…
guillaumegay13 Jun 5, 2026
5bb83ec
docs: refine global providers spec from code map (no scope flag, no a…
guillaumegay13 Jun 5, 2026
be5245f
docs: implementation plan for global provider connections (port of Se…
guillaumegay13 Jun 5, 2026
9b60db0
feat(providers): make user_providers.agent_id nullable for global con…
guillaumegay13 Jun 5, 2026
b8b4313
feat(providers): add LiftAgentProvidersToGlobal migration (relabel + …
guillaumegay13 Jun 5, 2026
433f290
feat(routing): split routing cache into agent- and user-scoped with i…
guillaumegay13 Jun 5, 2026
473bdb6
feat(routing): user-scope provider services + fan-out (B2-B8)
guillaumegay13 Jun 5, 2026
4afe920
feat(routing): user-scope controllers + OAuth call sites (B9, C)
guillaumegay13 Jun 5, 2026
3a87175
test(routing): user-scope oauth + controller specs
guillaumegay13 Jun 5, 2026
144bcbd
test(routing): user-scope provider/tier/resolve/proxy service specs
guillaumegay13 Jun 5, 2026
a2afc62
feat(discovery): user-scope model discovery + specs
guillaumegay13 Jun 5, 2026
322ebd2
test(routing): thread userId through custom-provider service specs
guillaumegay13 Jun 5, 2026
16abb98
test(routing): tier auto-assign recalculate takes userId
guillaumegay13 Jun 5, 2026
d30159d
test(discovery): cover user-scoped custom-provider merge branches
guillaumegay13 Jun 5, 2026
9f018c8
feat(providers): add slim GET /api/v1/providers list endpoint
guillaumegay13 Jun 5, 2026
610046a
feat(frontend): add initialTab + deeplink fields to provider connect …
guillaumegay13 Jun 5, 2026
3f59ee4
feat(frontend): add Subscriptions/BYOK/Local global provider pages
guillaumegay13 Jun 5, 2026
0739b2b
feat(frontend): add global provider routes + sidebar section
guillaumegay13 Jun 5, 2026
7f0edda
test(providers): e2e global connection visible across agents
guillaumegay13 Jun 5, 2026
dea6924
chore: changeset for global provider connections
guillaumegay13 Jun 5, 2026
c39734a
fix(routing): complete userId caller threading missed by the re-key
guillaumegay13 Jun 6, 2026
1dd77ea
fix(test): hard-delete user_providers in multi-key e2e beforeEach
guillaumegay13 Jun 6, 2026
09e0dcc
fix(test): scope provider/tier e2e seed rows by user_id not tenant/agent
guillaumegay13 Jun 6, 2026
9fcc2df
fix(routing): replace per-agent recalculateTiers with recalculateTier…
guillaumegay13 Jun 6, 2026
edd8c08
fix(ui): wire ProviderDetailView close button to onClose (dismiss modal)
guillaumegay13 Jun 6, 2026
88990fb
fix(routing): make userId required in getTiers and always run provide…
guillaumegay13 Jun 6, 2026
0d13889
fix(routing): revert custom providers to fully agent-scoped cache and…
guillaumegay13 Jun 6, 2026
e0b5808
fix(playground): correct error message from 'for this agent' to 'for …
guillaumegay13 Jun 6, 2026
d0599eb
refactor(routing): extract serializeProviderConnection to shared prov…
guillaumegay13 Jun 6, 2026
e070475
fix(routing): user-scope custom-provider reads consumed by user-scope…
guillaumegay13 Jun 6, 2026
ce449b1
chore: drop internal planning docs from PR (kept in branch history)
guillaumegay13 Jun 6, 2026
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
5 changes: 5 additions & 0 deletions .changeset/global-provider-connections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'manifest': minor
---

Add global (user-level) provider connections: connect each provider or subscription once and every one of your agents can use it. Existing agent-scoped connections are migrated up to the global pool (relabeled per source agent, never duplicated). New Subscriptions / BYOK / Local pages in the sidebar manage connections.
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ describe('OverviewController', () => {

expect(result.has_providers).toBe(true);
expect(mockResolveAgent).toHaveBeenCalledWith('u1', 'bot-1');
expect(mockGetProviders).toHaveBeenCalledWith('agent-uuid-1');
// Provider reads are user-scoped — passing the agentId here would query
// user_providers by an agentId and silently return no providers.
expect(mockGetProviders).toHaveBeenCalledWith('u1');
});

it('returns has_providers false when no providers exist', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ export class OverviewController {
private async hasActiveProviders(userId: string, agentName?: string): Promise<boolean> {
if (!agentName) return false;
try {
const agent = await this.resolveAgent.resolve(userId, agentName);
const providers = await this.providerService.getProviders(agent.id);
// Resolve still runs so an unknown/foreign agentName surfaces as "no
// providers" (throws → caught below), but provider reads are user-scoped.
await this.resolveAgent.resolve(userId, agentName);
const providers = await this.providerService.getProviders(userId);
return providers.some((p) => p.is_active);
} catch {
return false;
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/database/database.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import { AddReasoningContentCache1790100000000 } from './migrations/179010000000
import { AddDedupCompositeIndex1790200000000 } from './migrations/1790200000000-AddDedupCompositeIndex';
import { AddErrorsPartialIndex1790300000000 } from './migrations/1790300000000-AddErrorsPartialIndex';
import { DropRedundantAgentApiKeyPrefixIndex1790400000000 } from './migrations/1790400000000-DropRedundantAgentApiKeyPrefixIndex';
import { LiftAgentProvidersToGlobal1791000000000 } from './migrations/1791000000000-LiftAgentProvidersToGlobal';

const entities = [
AgentMessage,
Expand Down Expand Up @@ -225,6 +226,7 @@ const migrations = [
AddDedupCompositeIndex1790200000000,
AddErrorsPartialIndex1790300000000,
DropRedundantAgentApiKeyPrefixIndex1790400000000,
LiftAgentProvidersToGlobal1791000000000,
];

@Module({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import { Client } from 'pg';
import { DataSource, QueryRunner } from 'typeorm';
import { LiftAgentProvidersToGlobal1791000000000 } from './1791000000000-LiftAgentProvidersToGlobal';

/**
* This spec executes the real migration against a live PostgreSQL database so
* that both up() and down() are exercised end-to-end. It creates a throwaway
* database, builds the minimal schema the migration touches (user_providers +
* agents), seeds collision scenarios, and asserts on the resulting rows and
* indexes via pg_indexes.
*/

const ADMIN_URL =
process.env['MIGRATION_DATABASE_URL'] ??
process.env['DATABASE_URL'] ??
'postgresql://myuser:mypassword@localhost:5432/postgres';

function baseUrlFor(dbName: string): string {
const url = new URL(ADMIN_URL);
url.pathname = `/${dbName}`;
return url.toString();
}

const OLD_INDEX = 'IDX_user_providers_agent_provider_auth_label';
const NEW_INDEX = 'IDX_user_providers_user_provider_auth_label';

describe('LiftAgentProvidersToGlobal1791000000000 (live DB)', () => {
const migration = new LiftAgentProvidersToGlobal1791000000000();
const dbName = `manifest_mig_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
let dataSource: DataSource;
let runner: QueryRunner;

async function indexExists(name: string): Promise<boolean> {
const rows: Array<{ indexname: string }> = await runner.query(
`SELECT indexname FROM pg_indexes WHERE tablename = 'user_providers' AND indexname = $1`,
[name],
);
return rows.length === 1;
}

async function seedSchema(): Promise<void> {
await runner.query(`
CREATE TABLE "agents" (
"id" varchar PRIMARY KEY,
"name" varchar,
"display_name" varchar
)
`);
await runner.query(`
CREATE TABLE "user_providers" (
"id" varchar PRIMARY KEY,
"user_id" varchar NOT NULL,
"agent_id" varchar NOT NULL,
"provider" varchar NOT NULL,
"auth_type" varchar NOT NULL DEFAULT 'api_key',
"label" varchar NOT NULL DEFAULT 'Default',
"priority" integer NOT NULL DEFAULT 0,
"connected_at" timestamp NOT NULL DEFAULT now()
)
`);
// The old agent-scoped unique index that up() must drop.
await runner.query(`
CREATE UNIQUE INDEX "${OLD_INDEX}"
ON "user_providers" ("agent_id", "provider", "auth_type", LOWER("label"))
`);
}

beforeAll(async () => {
const admin = new Client({ connectionString: ADMIN_URL });
await admin.connect();
await admin.query(`CREATE DATABASE "${dbName}"`);
await admin.end();

dataSource = new DataSource({ type: 'postgres', url: baseUrlFor(dbName) });
await dataSource.initialize();
runner = dataSource.createQueryRunner();
await seedSchema();
});

afterAll(async () => {
if (runner) await runner.release();
if (dataSource?.isInitialized) await dataSource.destroy();
const admin = new Client({ connectionString: ADMIN_URL });
await admin.connect();
// Terminate stragglers then drop the throwaway DB.
await admin.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`,
[dbName],
);
await admin.query(`DROP DATABASE IF EXISTS "${dbName}"`);
await admin.end();
});

beforeEach(async () => {
await runner.query(`DELETE FROM "user_providers"`);
await runner.query(`DELETE FROM "agents"`);
// Reset the schema to the pre-up() baseline (old agent-scoped index present,
// new user-scoped index absent) so each test starts from the same state.
await runner.query(`DROP INDEX IF EXISTS "${NEW_INDEX}"`);
await runner.query(`DROP INDEX IF EXISTS "${OLD_INDEX}"`);
await runner.query(`
CREATE UNIQUE INDEX "${OLD_INDEX}"
ON "user_providers" ("agent_id", "provider", "auth_type", LOWER("label"))
`);
});

it('relabels colliding Default rows to agent display names, keeps every row, and swaps the index', async () => {
await runner.query(
`INSERT INTO "agents" ("id", "name", "display_name") VALUES ($1, $2, $3), ($4, $5, $6)`,
['agent-1', 'agent-one', 'Sales Bot', 'agent-2', 'agent-two', 'Support Bot'],
);
await runner.query(
`INSERT INTO "user_providers" ("id", "user_id", "agent_id", "provider", "auth_type", "label")
VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12)`,
[
'up-1',
'user-1',
'agent-1',
'openai',
'api_key',
'Default',
'up-2',
'user-1',
'agent-2',
'openai',
'api_key',
'Default',
],
);

const before: Array<{ c: string }> = await runner.query(
`SELECT COUNT(*)::text AS c FROM "user_providers"`,
);

await migration.up(runner);

const after: Array<{ c: string }> = await runner.query(
`SELECT COUNT(*)::text AS c FROM "user_providers"`,
);
// No rows deleted.
expect(after[0].c).toBe(before[0].c);
expect(after[0].c).toBe('2');

const rows: Array<{ id: string; label: string; agent_id: string | null }> = await runner.query(
`SELECT "id", "label", "agent_id" FROM "user_providers" ORDER BY "id"`,
);
const labels = rows.map((r) => r.label).sort();
expect(labels).toEqual(['Sales Bot', 'Support Bot']);
// agent_id is never nulled by this migration.
expect(rows.every((r) => r.agent_id !== null)).toBe(true);

expect(await indexExists(NEW_INDEX)).toBe(true);
expect(await indexExists(OLD_INDEX)).toBe(false);
});

it('relabels a colliding custom label as "<label> - <agentName>"', async () => {
// agent-2 has no display_name, so the relabel falls back to "name".
await runner.query(
`INSERT INTO "agents" ("id", "name", "display_name") VALUES ($1, $2, $3), ($4, $5, $6)`,
['agent-1', 'agent-one', 'Sales Bot', 'agent-2', 'agent-two', null],
);
await runner.query(
`INSERT INTO "user_providers" ("id", "user_id", "agent_id", "provider", "auth_type", "label")
VALUES ($1, $2, $3, $4, $5, $6), ($7, $8, $9, $10, $11, $12)`,
[
'up-1',
'user-1',
'agent-1',
'openai',
'api_key',
'Prod Key',
'up-2',
'user-1',
'agent-2',
'openai',
'api_key',
'Prod Key',
],
);

await migration.up(runner);

const rows: Array<{ id: string; label: string }> = await runner.query(
`SELECT "id", "label" FROM "user_providers" ORDER BY "id"`,
);
const labels = rows.map((r) => r.label).sort();
expect(labels).toEqual(['Prod Key - Sales Bot', 'Prod Key - agent-two']);
expect(await indexExists(NEW_INDEX)).toBe(true);
});

it('leaves a non-colliding row unchanged', async () => {
await runner.query(`INSERT INTO "agents" ("id", "name", "display_name") VALUES ($1, $2, $3)`, [
'agent-1',
'agent-one',
'Sales Bot',
]);
await runner.query(
`INSERT INTO "user_providers" ("id", "user_id", "agent_id", "provider", "auth_type", "label")
VALUES ($1, $2, $3, $4, $5, $6)`,
['up-solo', 'user-9', 'agent-1', 'anthropic', 'api_key', 'My Anthropic'],
);

await migration.up(runner);

const rows: Array<{ label: string }> = await runner.query(
`SELECT "label" FROM "user_providers" WHERE "id" = 'up-solo'`,
);
expect(rows[0].label).toBe('My Anthropic');
});

it('down() drops the user-scoped index and recreates the agent-scoped one', async () => {
// Bring the schema to the post-up() state first.
await migration.up(runner);
expect(await indexExists(NEW_INDEX)).toBe(true);

await migration.down(runner);

expect(await indexExists(NEW_INDEX)).toBe(false);
expect(await indexExists(OLD_INDEX)).toBe(true);

// Re-establish the post-up() index for any subsequent runs / idempotency.
await migration.up(runner);
expect(await indexExists(NEW_INDEX)).toBe(true);
});
});
Loading
Loading