diff --git a/assistant/openapi.yaml b/assistant/openapi.yaml index be7cd62dfde..82ec8f937ab 100644 --- a/assistant/openapi.yaml +++ b/assistant/openapi.yaml @@ -8600,6 +8600,16 @@ paths: - type - credential additionalProperties: false + status: + type: string + enum: + - active + - disabled + label: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: integer minimum: -9007199254740991 @@ -8612,6 +8622,8 @@ paths: - name - provider - auth + - status + - label - createdAt - updatedAt additionalProperties: false @@ -8705,6 +8717,16 @@ paths: - type - credential additionalProperties: false + status: + type: string + enum: + - active + - disabled + label: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: integer minimum: -9007199254740991 @@ -8717,6 +8739,8 @@ paths: - name - provider - auth + - status + - label - createdAt - updatedAt additionalProperties: false @@ -8797,6 +8821,14 @@ paths: - type - credential additionalProperties: false + label: + type: string + minLength: 1 + status: + type: string + enum: + - active + - disabled required: - name - provider @@ -8913,6 +8945,16 @@ paths: - type - credential additionalProperties: false + status: + type: string + enum: + - active + - disabled + label: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: integer minimum: -9007199254740991 @@ -8925,6 +8967,8 @@ paths: - name - provider - auth + - status + - label - createdAt - updatedAt additionalProperties: false @@ -8938,8 +8982,8 @@ paths: type: string patch: operationId: inference_providerconnections_by_name_patch - summary: Update a provider connection's auth - description: Update the auth configuration for an existing connection. Cannot rename or change the provider. + summary: Update a provider connection + description: Update an existing connection. Cannot rename or change the provider. tags: - inference responses: @@ -9016,6 +9060,16 @@ paths: - type - credential additionalProperties: false + status: + type: string + enum: + - active + - disabled + label: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: integer minimum: -9007199254740991 @@ -9028,6 +9082,8 @@ paths: - name - provider - auth + - status + - label - createdAt - updatedAt additionalProperties: false @@ -9102,6 +9158,16 @@ paths: - type - credential additionalProperties: false + status: + type: string + enum: + - active + - disabled + label: + anyOf: + - type: string + minLength: 1 + - type: "null" required: - auth additionalProperties: false diff --git a/assistant/src/__tests__/profile-entry-status.test.ts b/assistant/src/__tests__/profile-entry-status.test.ts new file mode 100644 index 00000000000..b81d2f4cd8d --- /dev/null +++ b/assistant/src/__tests__/profile-entry-status.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; + +import { ProfileEntry } from "../config/schemas/llm.js"; + +describe("ProfileEntry status field", () => { + test("parses a profile with status: disabled", () => { + const result = ProfileEntry.safeParse({ + status: "disabled", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("disabled"); + } + }); + + test("parses a profile without status — status is undefined (treated as active)", () => { + const result = ProfileEntry.safeParse({ + provider: "anthropic", + model: "claude-opus-4-7", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBeUndefined(); + } + }); + + test("parses a profile with status: active", () => { + const result = ProfileEntry.safeParse({ + status: "active", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.status).toBe("active"); + } + }); + + test("rejects an invalid status value", () => { + const result = ProfileEntry.safeParse({ + status: "hidden", + }); + expect(result.success).toBe(false); + }); +}); diff --git a/assistant/src/config/schemas/llm.ts b/assistant/src/config/schemas/llm.ts index 22313e58ed2..13fe79d7d23 100644 --- a/assistant/src/config/schemas/llm.ts +++ b/assistant/src/config/schemas/llm.ts @@ -326,6 +326,9 @@ const LLMConfigFragment = z.object({ }); type LLMConfigFragment = z.infer; +export const ProfileStatusSchema = z.enum(["active", "disabled"]); +export type ProfileStatus = z.infer; + /** * A named profile entry: an `LLMConfigFragment` augmented with * presentation/ownership metadata. These fields are intentionally kept off @@ -343,6 +346,8 @@ export const ProfileEntry = LLMConfigFragment.extend({ * not yet backfilled by the boot-time migration. */ provider_connection: z.string().min(1).optional(), + /** Absent means active. */ + status: ProfileStatusSchema.optional(), }); export type ProfileEntry = z.infer; diff --git a/assistant/src/memory/db-init.ts b/assistant/src/memory/db-init.ts index 9c3bddd570b..4d3b3790254 100644 --- a/assistant/src/memory/db-init.ts +++ b/assistant/src/memory/db-init.ts @@ -137,6 +137,7 @@ import { migrateOAuthProvidersScopeSeparator, migrateOAuthProvidersTokenAuthMethodDefault, migrateOAuthProvidersTokenExchangeBodyFormat, + migrateProviderConnectionStatusLabel, migrateReminderRoutingIntent, migrateRemindersToSchedules, migrateRenameConversationTypeColumn, @@ -418,6 +419,7 @@ export function initializeDb(): void { migrateConversationInferenceProfileSession, migrateMessageBookmarks, migrateCreateProviderConnections, + migrateProviderConnectionStatusLabel, ]; // Run each migration step, catching and logging individual failures so one diff --git a/assistant/src/memory/migrations/244-provider-connection-status-label.ts b/assistant/src/memory/migrations/244-provider-connection-status-label.ts new file mode 100644 index 00000000000..f3d1e8f2c03 --- /dev/null +++ b/assistant/src/memory/migrations/244-provider-connection-status-label.ts @@ -0,0 +1,23 @@ +import { type DrizzleDb, getSqliteFrom } from "../db-connection.js"; + +/** + * Adds `status` (NOT NULL DEFAULT 'active') and `label` (nullable) columns + * to the `provider_connections` table. + * + * Idempotent: reads PRAGMA table_info before each ALTER so re-running on a + * database that already has the columns is a no-op. + */ +export function migrateProviderConnectionStatusLabel(database: DrizzleDb): void { + const raw = getSqliteFrom(database); + + const columns = raw.query(`PRAGMA table_info(provider_connections)`).all() as Array<{ name: string }>; + const columnNames = new Set(columns.map((c) => c.name)); + + if (!columnNames.has("status")) { + raw.exec(`ALTER TABLE provider_connections ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`); + } + + if (!columnNames.has("label")) { + raw.exec(`ALTER TABLE provider_connections ADD COLUMN label TEXT`); + } +} diff --git a/assistant/src/memory/migrations/__tests__/244-provider-connection-status-label.test.ts b/assistant/src/memory/migrations/__tests__/244-provider-connection-status-label.test.ts new file mode 100644 index 00000000000..384ff9e1416 --- /dev/null +++ b/assistant/src/memory/migrations/__tests__/244-provider-connection-status-label.test.ts @@ -0,0 +1,84 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; + +import { drizzle } from "drizzle-orm/bun-sqlite"; + +import { getSqliteFrom } from "../../db-connection.js"; +import * as schema from "../../schema.js"; +import { migrateCreateProviderConnections } from "../243-provider-connections.js"; +import { migrateProviderConnectionStatusLabel } from "../244-provider-connection-status-label.js"; + +interface ColumnRow { + name: string; + type: string; + notnull: number; + dflt_value: string | null; + pk: number; +} + +function createTestDb() { + const sqlite = new Database(":memory:"); + sqlite.exec("PRAGMA journal_mode=WAL"); + sqlite.exec("PRAGMA foreign_keys = ON"); + return drizzle(sqlite, { schema }); +} + +describe("migration 244 — provider_connection status + label", () => { + test("adds status (NOT NULL DEFAULT active) and label (nullable) columns", () => { + const db = createTestDb(); + const raw = getSqliteFrom(db); + + // Bootstrap provider_connections table via migration 243. + migrateCreateProviderConnections(db); + + // Verify the new columns are absent before migration 244. + const colsBefore = raw.query(`PRAGMA table_info(provider_connections)`).all() as ColumnRow[]; + const namesBefore = colsBefore.map((c) => c.name); + expect(namesBefore).not.toContain("status"); + expect(namesBefore).not.toContain("label"); + + migrateProviderConnectionStatusLabel(db); + + const cols = raw.query(`PRAGMA table_info(provider_connections)`).all() as ColumnRow[]; + const colMap = Object.fromEntries(cols.map((c) => [c.name, c])); + + // status: NOT NULL, default 'active' + expect(colMap["status"]).toBeDefined(); + expect(colMap["status"].notnull).toBe(1); + expect(colMap["status"].dflt_value).toBe("'active'"); + + // label: nullable + expect(colMap["label"]).toBeDefined(); + expect(colMap["label"].notnull).toBe(0); + expect(colMap["label"].dflt_value).toBeNull(); + }); + + test("is idempotent — running twice does not throw", () => { + const db = createTestDb(); + migrateCreateProviderConnections(db); + migrateProviderConnectionStatusLabel(db); + expect(() => migrateProviderConnectionStatusLabel(db)).not.toThrow(); + }); + + test("existing rows get status=active from column default", () => { + const db = createTestDb(); + const raw = getSqliteFrom(db); + + // Seed a row before adding the status column. + migrateCreateProviderConnections(db); + + // Verify canonical connections got the default. + migrateProviderConnectionStatusLabel(db); + + const rows = raw.query(`SELECT name, status, label FROM provider_connections`).all() as Array<{ + name: string; + status: string; + label: string | null; + }>; + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.status).toBe("active"); + expect(row.label).toBeNull(); + } + }); +}); diff --git a/assistant/src/memory/migrations/index.ts b/assistant/src/memory/migrations/index.ts index 1395bc2a1ed..7b5f29fa1cd 100644 --- a/assistant/src/memory/migrations/index.ts +++ b/assistant/src/memory/migrations/index.ts @@ -205,6 +205,7 @@ export { migrateConversationInferenceProfileSession } from "./240-conversation-i export { migrateActivationStateFkCascade } from "./241-activation-state-fk-cascade.js"; export { migrateMessageBookmarks } from "./242-message-bookmarks.js"; export { migrateCreateProviderConnections } from "./243-provider-connections.js"; +export { migrateProviderConnectionStatusLabel } from "./244-provider-connection-status-label.js"; export { MIGRATION_REGISTRY, type MigrationRegistryEntry, diff --git a/assistant/src/memory/schema/inference.ts b/assistant/src/memory/schema/inference.ts index b5d85c60967..3da3ea6f541 100644 --- a/assistant/src/memory/schema/inference.ts +++ b/assistant/src/memory/schema/inference.ts @@ -15,6 +15,8 @@ export const providerConnections = sqliteTable( name: text("name").primaryKey(), provider: text("provider").notNull(), auth: text("auth").notNull(), + status: text("status").notNull().default("active"), + label: text("label"), createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), }, diff --git a/assistant/src/providers/__tests__/inference.test.ts b/assistant/src/providers/__tests__/inference.test.ts index 8957fd0fff9..e5ea06ca1bd 100644 --- a/assistant/src/providers/__tests__/inference.test.ts +++ b/assistant/src/providers/__tests__/inference.test.ts @@ -11,6 +11,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite"; import type { DrizzleDb } from "../../memory/db-connection.js"; import { getSqliteFrom } from "../../memory/db-connection.js"; import { migrateCreateProviderConnections } from "../../memory/migrations/243-provider-connections.js"; +import { migrateProviderConnectionStatusLabel } from "../../memory/migrations/244-provider-connection-status-label.js"; import * as schema from "../../memory/schema.js"; import { AuthSchema } from "../inference/auth.js"; import { @@ -33,6 +34,7 @@ function setupDb(): { db: DrizzleDb; raw: Database } { const db = drizzle(sqlite, { schema }); const raw = getSqliteFrom(db); migrateCreateProviderConnections(db); + migrateProviderConnectionStatusLabel(db); return { db, raw }; } diff --git a/assistant/src/providers/inference/__tests__/connections-status-label.test.ts b/assistant/src/providers/inference/__tests__/connections-status-label.test.ts new file mode 100644 index 00000000000..177b576bfb8 --- /dev/null +++ b/assistant/src/providers/inference/__tests__/connections-status-label.test.ts @@ -0,0 +1,107 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, test } from "bun:test"; + +import { drizzle } from "drizzle-orm/bun-sqlite"; + +import { migrateCreateProviderConnections } from "../../../memory/migrations/243-provider-connections.js"; +import { migrateProviderConnectionStatusLabel } from "../../../memory/migrations/244-provider-connection-status-label.js"; +import * as schema from "../../../memory/schema.js"; +import { createConnection, getConnection, updateConnection } from "../connections.js"; + +function createTestDb() { + const sqlite = new Database(":memory:"); + sqlite.exec("PRAGMA journal_mode=WAL"); + return drizzle(sqlite, { schema }); +} + +function bootDb() { + const db = createTestDb(); + migrateCreateProviderConnections(db); + migrateProviderConnectionStatusLabel(db); + return db; +} + +describe("connection CRUD status + label defaults", () => { + test("new connection without status/label gets status=active and label=null", () => { + const db = bootDb(); + const result = createConnection(db, { + name: "my-conn", + provider: "anthropic", + auth: { type: "platform" }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.connection.status).toBe("active"); + expect(result.connection.label).toBeNull(); + } + }); + + test("createConnection passes explicit status and label", () => { + const db = bootDb(); + const result = createConnection(db, { + name: "disabled-conn", + provider: "openai", + auth: { type: "platform" }, + status: "disabled", + label: "My OpenAI", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.connection.status).toBe("disabled"); + expect(result.connection.label).toBe("My OpenAI"); + } + }); + + test("getConnection returns status and label from DB", () => { + const db = bootDb(); + createConnection(db, { + name: "get-me", + provider: "gemini", + auth: { type: "platform" }, + status: "disabled", + label: "Gemini Pro", + }); + + const conn = getConnection(db, "get-me"); + expect(conn).not.toBeNull(); + expect(conn!.status).toBe("disabled"); + expect(conn!.label).toBe("Gemini Pro"); + }); + + test("updateConnection updates status", () => { + const db = bootDb(); + createConnection(db, { + name: "toggle-me", + provider: "anthropic", + auth: { type: "platform" }, + }); + + const result = updateConnection(db, "toggle-me", { + auth: { type: "platform" }, + status: "disabled", + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.connection.status).toBe("disabled"); + } + }); + + test("updateConnection clears label when set to null", () => { + const db = bootDb(); + createConnection(db, { + name: "clear-label", + provider: "openai", + auth: { type: "platform" }, + label: "Old Label", + }); + + const result = updateConnection(db, "clear-label", { + auth: { type: "platform" }, + label: null, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.connection.label).toBeNull(); + } + }); +}); diff --git a/assistant/src/providers/inference/auth.ts b/assistant/src/providers/inference/auth.ts index f29a8363394..bf1d818658f 100644 --- a/assistant/src/providers/inference/auth.ts +++ b/assistant/src/providers/inference/auth.ts @@ -70,6 +70,13 @@ export type ConnectionProvider = typeof VALID_CONNECTION_PROVIDERS[number]; export const ConnectionProviderSchema = z.enum(VALID_CONNECTION_PROVIDERS); +// --------------------------------------------------------------------------- +// Connection status +// --------------------------------------------------------------------------- + +export const ConnectionStatusSchema = z.enum(["active", "disabled"]); +export type ConnectionStatus = z.infer; + // --------------------------------------------------------------------------- // Full connection shape used by CRUD layer // --------------------------------------------------------------------------- @@ -78,6 +85,8 @@ export const ProviderConnectionSchema = z.object({ name: z.string().min(1), provider: ConnectionProviderSchema, auth: AuthSchema, + status: ConnectionStatusSchema, + label: z.string().min(1).nullable(), createdAt: z.number().int(), updatedAt: z.number().int(), }); diff --git a/assistant/src/providers/inference/connections.ts b/assistant/src/providers/inference/connections.ts index f8f492f67b3..aa04fca27d9 100644 --- a/assistant/src/providers/inference/connections.ts +++ b/assistant/src/providers/inference/connections.ts @@ -8,6 +8,8 @@ import { AuthSchema, type ConnectionProvider, ConnectionProviderSchema, + type ConnectionStatus, + ConnectionStatusSchema, type ProviderConnection, VALID_CONNECTION_PROVIDERS, } from "./auth.js"; @@ -29,7 +31,9 @@ export function listConnections( if (!auth.success) return []; const provider = ConnectionProviderSchema.safeParse(row.provider); if (!provider.success) return []; - return [{ ...row, auth: auth.data, provider: provider.data }]; + const statusResult = ConnectionStatusSchema.safeParse(row.status); + const status: ConnectionStatus = statusResult.success ? statusResult.data : "active"; + return [{ ...row, auth: auth.data, provider: provider.data, status, label: row.label ?? null }]; }); } @@ -48,7 +52,9 @@ export function getConnection( if (!auth.success) return null; const provider = ConnectionProviderSchema.safeParse(row.provider); if (!provider.success) return null; - return { ...row, auth: auth.data, provider: provider.data }; + const statusResult = ConnectionStatusSchema.safeParse(row.status); + const status: ConnectionStatus = statusResult.success ? statusResult.data : "active"; + return { ...row, auth: auth.data, provider: provider.data, status, label: row.label ?? null }; } // --------------------------------------------------------------------------- @@ -59,10 +65,14 @@ export type CreateConnectionInput = { name: string; provider: string; auth: Auth; + status?: ConnectionStatus; + label?: string | null; }; export type UpdateConnectionInput = { auth: Auth; + status?: ConnectionStatus; + label?: string | null; }; export type ConnectionCreateError = @@ -102,11 +112,16 @@ export function createConnection( return { ok: false, error: { code: "already_exists" } }; } + const status = input.status ?? "active"; + const label = input.label ?? null; + const now = Date.now(); db.insert(providerConnections).values({ name: input.name, provider, auth: JSON.stringify(authResult.data), + status, + label, createdAt: now, updatedAt: now, }).run(); @@ -121,6 +136,8 @@ export function createConnection( name: input.name, provider, auth: authResult.data, + status, + label, createdAt: now, updatedAt: now, }, @@ -143,8 +160,17 @@ export function updateConnection( } const now = Date.now(); + const setClause: { + auth: string; + updatedAt: number; + status?: string; + label?: string | null; + } = { auth: JSON.stringify(authResult.data), updatedAt: now }; + if (input.status !== undefined) setClause.status = input.status; + if (input.label !== undefined) setClause.label = input.label; + db.update(providerConnections) - .set({ auth: JSON.stringify(authResult.data), updatedAt: now }) + .set(setClause) .where(eq(providerConnections.name, name)) .run(); @@ -153,7 +179,13 @@ export function updateConnection( return { ok: true, - connection: { ...existing, auth: authResult.data, updatedAt: now }, + connection: { + ...existing, + auth: authResult.data, + status: input.status !== undefined ? input.status : existing.status, + label: input.label !== undefined ? input.label : existing.label, + updatedAt: now, + }, }; } diff --git a/assistant/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts b/assistant/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts index a884897bd10..17139072d8e 100644 --- a/assistant/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +++ b/assistant/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts @@ -384,6 +384,96 @@ describe("DELETE inference/provider-connections/:name (delete)", () => { }); }); +// ── status + label fields ───────────────────────────────────────────────────── + +describe("POST with label and status", () => { + test("creates connection with label and status, both echoed in response", async () => { + const result = (await call( + findHandler("inference_provider_connections_create"), + { + body: { + name: "labeled-conn", + provider: "anthropic", + auth: { type: "platform" }, + label: "My Anthropic", + status: "active", + }, + }, + )) as { name: string; label: string | null; status: string }; + expect(result.name).toBe("labeled-conn"); + expect(result.label).toBe("My Anthropic"); + expect(result.status).toBe("active"); + }); + + test("creates connection without label — label is null in response", async () => { + const result = (await call( + findHandler("inference_provider_connections_create"), + { + body: { name: "no-label-conn", provider: "openai", auth: { type: "platform" } }, + }, + )) as { label: string | null; status: string }; + expect(result.label).toBeNull(); + expect(result.status).toBe("active"); + }); +}); + +describe("PATCH with status and label", () => { + test("updates status to disabled", async () => { + seedConnection({ name: "toggleable", provider: "anthropic", auth: { type: "platform" } }); + + const result = (await call( + findHandler("inference_provider_connections_update"), + { + pathParams: { name: "toggleable" }, + body: { auth: { type: "platform" }, status: "disabled" }, + }, + )) as { status: string }; + expect(result.status).toBe("disabled"); + }); + + test("updates label to a string", async () => { + seedConnection({ name: "set-label", provider: "openai", auth: { type: "platform" } }); + + const result = (await call( + findHandler("inference_provider_connections_update"), + { + pathParams: { name: "set-label" }, + body: { auth: { type: "platform" }, label: "My OpenAI" }, + }, + )) as { label: string | null }; + expect(result.label).toBe("My OpenAI"); + }); + + test("clears label by setting it to null", async () => { + seedConnection({ name: "clear-label", provider: "gemini", auth: { type: "platform" } }); + // First set a label. + await call(findHandler("inference_provider_connections_update"), { + pathParams: { name: "clear-label" }, + body: { auth: { type: "platform" }, label: "Old Label" }, + }); + + const result = (await call( + findHandler("inference_provider_connections_update"), + { + pathParams: { name: "clear-label" }, + body: { auth: { type: "platform" }, label: null }, + }, + )) as { label: string | null }; + expect(result.label).toBeNull(); + }); + + test("rejects label: empty string with 400", async () => { + seedConnection({ name: "reject-empty", provider: "anthropic", auth: { type: "platform" } }); + + await expect( + call(findHandler("inference_provider_connections_update"), { + pathParams: { name: "reject-empty" }, + body: { auth: { type: "platform" }, label: "" }, + }), + ).rejects.toBeInstanceOf(BadRequestError); + }); +}); + // ── Auth / route-policy wiring ──────────────────────────────────────────────── describe("Route policy registrations", () => { diff --git a/assistant/src/runtime/routes/inference-provider-connection-routes.ts b/assistant/src/runtime/routes/inference-provider-connection-routes.ts index 5ef373f573d..14fdb6ced63 100644 --- a/assistant/src/runtime/routes/inference-provider-connection-routes.ts +++ b/assistant/src/runtime/routes/inference-provider-connection-routes.ts @@ -12,7 +12,7 @@ import { z } from "zod"; import { getConfigReadOnly } from "../../config/loader.js"; import { getDb } from "../../memory/db-connection.js"; -import { AuthSchema, ConnectionProviderSchema, ProviderConnectionSchema, VALID_CONNECTION_PROVIDERS } from "../../providers/inference/auth.js"; +import { AuthSchema, ConnectionProviderSchema, ConnectionStatusSchema, ProviderConnectionSchema, VALID_CONNECTION_PROVIDERS } from "../../providers/inference/auth.js"; import { createConnection, deleteConnection, @@ -74,10 +74,22 @@ function handleCreateConnection({ body = {} }: RouteHandlerArgs) { throw new BadRequestError(`Invalid auth: ${authResult.error.message}`); } + const statusResult = body.status !== undefined ? ConnectionStatusSchema.safeParse(body.status) : null; + if (statusResult && !statusResult.success) { + throw new BadRequestError(`Invalid status: must be "active" or "disabled"`); + } + + const labelRaw = body.label; + if (labelRaw !== undefined && labelRaw !== null && (typeof labelRaw !== "string" || labelRaw.length === 0)) { + throw new BadRequestError(`Invalid label: must be a non-empty string or null`); + } + const result = createConnection(getDb(), { name, provider: providerResult.data, auth: authResult.data, + ...(statusResult ? { status: statusResult.data } : {}), + ...(labelRaw !== undefined ? { label: labelRaw as string | null } : {}), }); if (!result.ok) { @@ -107,7 +119,21 @@ function handleUpdateConnection({ pathParams = {}, body = {} }: RouteHandlerArgs throw new BadRequestError(`Invalid auth: ${authResult.error.message}`); } - const result = updateConnection(getDb(), name, { auth: authResult.data }); + const statusResult = body.status !== undefined ? ConnectionStatusSchema.safeParse(body.status) : null; + if (statusResult && !statusResult.success) { + throw new BadRequestError(`Invalid status: must be "active" or "disabled"`); + } + + const labelRaw = body.label; + if (labelRaw !== undefined && labelRaw !== null && (typeof labelRaw !== "string" || labelRaw.length === 0)) { + throw new BadRequestError(`Invalid label: must be a non-empty string or null`); + } + + const result = updateConnection(getDb(), name, { + auth: authResult.data, + ...(statusResult ? { status: statusResult.data } : {}), + ...(labelRaw !== undefined ? { label: labelRaw as string | null } : {}), + }); if (!result.ok) { if (result.error.code === "not_found") { @@ -215,6 +241,8 @@ export const ROUTES: RouteDefinition[] = [ name: z.string().min(1), provider: ConnectionProviderSchema, auth: AuthSchema, + label: z.string().min(1).optional(), + status: ConnectionStatusSchema.optional(), }), responseBody: providerConnectionResponseSchema, responseStatus: "201", @@ -229,12 +257,16 @@ export const ROUTES: RouteDefinition[] = [ endpoint: "inference/provider-connections/:name", method: "PATCH", policyKey: "inference/provider-connections/detail", - summary: "Update a provider connection's auth", + summary: "Update a provider connection", description: - "Update the auth configuration for an existing connection. Cannot rename or change the provider.", + "Update an existing connection. Cannot rename or change the provider.", tags: ["inference"], pathParams: [{ name: "name", description: "Connection name" }], - requestBody: z.object({ auth: AuthSchema }), + requestBody: z.object({ + auth: AuthSchema, + status: ConnectionStatusSchema.optional(), + label: z.string().min(1).nullable().optional(), + }), responseBody: providerConnectionResponseSchema, additionalResponses: { "400": { description: "Invalid auth schema" }, diff --git a/clients/macos/vellum-assistant/Features/Chat/ChatProfilePicker.swift b/clients/macos/vellum-assistant/Features/Chat/ChatProfilePicker.swift index dae305ce0e9..5a6967536ac 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ChatProfilePicker.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ChatProfilePicker.swift @@ -66,7 +66,8 @@ struct ChatProfilePicker: View { } var body: some View { - let pillLabel = Self.label(current: current, profiles: profiles, activeProfile: activeProfile) + let activeProfiles = profiles.filter { !$0.isDisabled } + let pillLabel = Self.label(current: current, profiles: activeProfiles, activeProfile: activeProfile) #if os(macOS) ComposerPillMenu( isEnabled: isEnabled, @@ -81,7 +82,7 @@ struct ChatProfilePicker: View { .foregroundStyle(VColor.contentSecondary) .lineLimit(1) } menu: { - ForEach(profiles) { profile in + ForEach(activeProfiles) { profile in VMenuItem( icon: VIcon.sparkles.rawValue, label: profile.displayName, @@ -100,7 +101,7 @@ struct ChatProfilePicker: View { } VMenuItem( icon: VIcon.rotateCcw.rawValue, - label: "Reset to default (\(profiles.first { $0.name == activeProfile }?.displayName ?? activeProfile))", + label: "Reset to default (\(activeProfiles.first { $0.name == activeProfile }?.displayName ?? activeProfile))", isActive: current == nil, size: .regular ) { diff --git a/clients/macos/vellum-assistant/Features/Settings/InferenceProfile.swift b/clients/macos/vellum-assistant/Features/Settings/InferenceProfile.swift index 86a504dcdd8..4ce3d3c2df2 100644 --- a/clients/macos/vellum-assistant/Features/Settings/InferenceProfile.swift +++ b/clients/macos/vellum-assistant/Features/Settings/InferenceProfile.swift @@ -30,6 +30,11 @@ public struct InferenceProfile: Hashable, Identifiable { /// that should be read-only in the UI. User-created profiles have `nil`. public var source: String? + /// Visibility status. `"active"` (default, represented as `nil` in the JSON) + /// means the profile is shown in pickers. `"disabled"` hides it from pickers + /// but keeps it visible in the profiles list so the user can re-enable it. + public var status: String? + /// Human-readable label for pickers and list rows (e.g. "Quality"). /// Falls back to `name` when absent — see `displayName`. public var label: String? @@ -73,6 +78,10 @@ public struct InferenceProfile: Hashable, Identifiable { /// profile to create a customizable variant. public var isManaged: Bool { source == "managed" } + /// Whether this profile is disabled. Disabled profiles are hidden from + /// picker UIs but remain visible in the profiles list for re-enabling. + public var isDisabled: Bool { status == "disabled" } + /// Label for pickers and list rows. Prefers the explicit `label` /// (e.g. "Quality") and falls back to `name`. public var displayName: String { label ?? name } @@ -83,6 +92,7 @@ public struct InferenceProfile: Hashable, Identifiable { public init( name: String, source: String? = nil, + status: String? = nil, label: String? = nil, profileDescription: String? = nil, provider: String? = nil, @@ -98,6 +108,7 @@ public struct InferenceProfile: Hashable, Identifiable { ) { self.name = name self.source = source + self.status = status self.label = label self.profileDescription = profileDescription self.provider = provider @@ -119,6 +130,7 @@ public struct InferenceProfile: Hashable, Identifiable { public init( name: String, source: String? = nil, + status: String? = nil, label: String? = nil, profileDescription: String? = nil, provider: String? = nil, @@ -135,6 +147,7 @@ public struct InferenceProfile: Hashable, Identifiable { self.init( name: name, source: source, + status: status, label: label, profileDescription: profileDescription, provider: provider, @@ -159,6 +172,7 @@ public struct InferenceProfile: Hashable, Identifiable { public init(name: String, json: [String: Any]) { self.name = name self.source = (json["source"] as? String).flatMap { $0.isEmpty ? nil : $0 } + self.status = (json["status"] as? String).flatMap { $0.isEmpty ? nil : $0 } self.label = (json["label"] as? String).flatMap { $0.isEmpty ? nil : $0 } self.profileDescription = (json["description"] as? String).flatMap { $0.isEmpty ? nil : $0 } self.provider = (json["provider"] as? String).flatMap { $0.isEmpty ? nil : $0 } @@ -182,6 +196,7 @@ public struct InferenceProfile: Hashable, Identifiable { /// with what the daemon will store after a partial-update PATCH. public func merging(_ fragment: InferenceProfile) -> InferenceProfile { var merged = self + if let v = fragment.status { merged.status = v } if let v = fragment.provider { merged.provider = v } if let v = fragment.model { merged.model = v } if let v = fragment.maxTokens { merged.maxTokens = v } @@ -205,6 +220,8 @@ public struct InferenceProfile: Hashable, Identifiable { public func toJSON() -> [String: Any] { var result = preservedJSON if let source { result["source"] = source } + // Omit status when active (absent == active convention); always include when disabled. + if let status, status == "disabled" { result["status"] = status } if let label { result["label"] = label } if let profileDescription { result["description"] = profileDescription } if let provider { result["provider"] = provider } @@ -245,6 +262,7 @@ public struct InferenceProfile: Hashable, Identifiable { var preserved = json for key in [ "source", + "status", "label", "description", "provider", @@ -289,6 +307,7 @@ public struct InferenceProfile: Hashable, Identifiable { public static func == (lhs: InferenceProfile, rhs: InferenceProfile) -> Bool { lhs.name == rhs.name && lhs.source == rhs.source + && lhs.status == rhs.status && lhs.label == rhs.label && lhs.profileDescription == rhs.profileDescription && lhs.provider == rhs.provider @@ -306,6 +325,7 @@ public struct InferenceProfile: Hashable, Identifiable { public func hash(into hasher: inout Hasher) { hasher.combine(name) hasher.combine(source) + hasher.combine(status) hasher.combine(label) hasher.combine(profileDescription) hasher.combine(provider) diff --git a/clients/macos/vellum-assistant/Features/Settings/InferenceProfileEditor.swift b/clients/macos/vellum-assistant/Features/Settings/InferenceProfileEditor.swift index d1bffaa72b0..47061f06b31 100644 --- a/clients/macos/vellum-assistant/Features/Settings/InferenceProfileEditor.swift +++ b/clients/macos/vellum-assistant/Features/Settings/InferenceProfileEditor.swift @@ -146,6 +146,7 @@ struct InferenceProfileEditor: View { if visibility.thinking { thinkingSection } + statusToggle } .padding(VSpacing.lg) } @@ -581,6 +582,18 @@ struct InferenceProfileEditor: View { } } + private var statusToggle: some View { + labeled("Status") { + VToggle( + isOn: Binding( + get: { profile.status != "disabled" }, + set: { profile.status = $0 ? nil : "disabled" } + ), + label: "Active" + ) + } + } + // MARK: - Helpers var selectedModelMaxOutputTokens: Int? { diff --git a/clients/macos/vellum-assistant/Features/Settings/ProvidersSheet.swift b/clients/macos/vellum-assistant/Features/Settings/ProvidersSheet.swift index 59e2fc438df..40bf1d87cac 100644 --- a/clients/macos/vellum-assistant/Features/Settings/ProvidersSheet.swift +++ b/clients/macos/vellum-assistant/Features/Settings/ProvidersSheet.swift @@ -18,6 +18,7 @@ struct ProvidersSheet: View { @State private var connections: [ProviderConnection] = [] @State private var editorState: EditorState? @State private var editorDraft = ConnectionDraft() + @State private var isKeyDirty = false @State private var conflictInfo: ConflictInfo? @State private var actionError: String? @@ -25,9 +26,11 @@ struct ProvidersSheet: View { struct ConnectionDraft { var name = "" + var label = "" var provider = "" var authType = "api_key" var credential = "" + var status: ConnectionStatus = .active } enum EditorState { @@ -77,6 +80,7 @@ struct ProvidersSheet: View { .onChange(of: editorState) { _, newValue in if newValue == nil { editorDraft = ConnectionDraft() + isKeyDirty = false } } .animation(VAnimation.fast, value: editorState != nil) @@ -171,20 +175,47 @@ struct ProvidersSheet: View { private func connectionRow(_ conn: ProviderConnection) -> some View { HStack(alignment: .center, spacing: VSpacing.md) { VStack(alignment: .leading, spacing: VSpacing.xxs) { - HStack(spacing: VSpacing.xs) { - Text(conn.name) + if let label = conn.label { + Text(label) .font(VFont.bodyMediumEmphasised) .foregroundStyle(VColor.contentDefault) - VBadge( - label: store.dynamicProviderDisplayName(conn.provider), - tone: .neutral, - emphasis: .subtle - ) - VBadge( - label: authTypeLabel(conn.auth.type), - tone: authTypeTone(conn.auth.type), - emphasis: .subtle - ) + HStack(spacing: VSpacing.xs) { + Text("@\(conn.name)") + .font(VFont.bodySmallDefault) + .foregroundStyle(VColor.contentSecondary) + VBadge( + label: store.dynamicProviderDisplayName(conn.provider), + tone: .neutral, + emphasis: .subtle + ) + VBadge( + label: authTypeLabel(conn.auth.type), + tone: authTypeTone(conn.auth.type), + emphasis: .subtle + ) + if conn.status == .disabled { + VBadge(label: "Disabled", tone: .warning, emphasis: .subtle) + } + } + } else { + HStack(spacing: VSpacing.xs) { + Text(conn.name) + .font(VFont.bodyMediumEmphasised) + .foregroundStyle(VColor.contentDefault) + VBadge( + label: store.dynamicProviderDisplayName(conn.provider), + tone: .neutral, + emphasis: .subtle + ) + VBadge( + label: authTypeLabel(conn.auth.type), + tone: authTypeTone(conn.auth.type), + emphasis: .subtle + ) + if conn.status == .disabled { + VBadge(label: "Disabled", tone: .warning, emphasis: .subtle) + } + } } } Spacer(minLength: 0) @@ -224,7 +255,8 @@ struct ProvidersSheet: View { SettingsDivider() ScrollView { VStack(alignment: .leading, spacing: VSpacing.md) { - editorNameField + editorLabelField + editorKeyField if case .create = editorState { editorProviderField } @@ -236,6 +268,7 @@ struct ProvidersSheet: View { } else if editorDraft.authType == "none" { editorNoneNote } + editorStatusToggle if let actionError { Text(actionError) .font(VFont.bodySmallDefault) @@ -276,19 +309,60 @@ struct ProvidersSheet: View { .padding(VSpacing.lg) } - private var editorNameField: some View { + private var editorLabelField: some View { VStack(alignment: .leading, spacing: VSpacing.xs) { - Text("Name") + Text("Display Name") + .font(VFont.labelDefault) + .foregroundStyle(VColor.contentSecondary) + VTextField( + placeholder: "e.g. My OpenAI", + text: Binding( + get: { editorDraft.label }, + set: { newValue in + editorDraft.label = newValue + if !isKeyDirty { + editorDraft.name = InferenceProfileEditor.toKebabCase(newValue) + } + } + ) + ) + } + } + + private var editorKeyField: some View { + VStack(alignment: .leading, spacing: VSpacing.xs) { + Text("Key") .font(VFont.labelDefault) .foregroundStyle(VColor.contentSecondary) VTextField( placeholder: "my-connection", - text: $editorDraft.name + text: Binding( + get: { editorDraft.name }, + set: { newValue in + isKeyDirty = true + editorDraft.name = newValue + } + ) ) .disabled(editorState != .create) } } + private var editorStatusToggle: some View { + VStack(alignment: .leading, spacing: VSpacing.xs) { + Text("Status") + .font(VFont.labelDefault) + .foregroundStyle(VColor.contentSecondary) + VToggle( + isOn: Binding( + get: { editorDraft.status == .active }, + set: { editorDraft.status = $0 ? .active : .disabled } + ), + label: "Active" + ) + } + } + private var editorProviderField: some View { VStack(alignment: .leading, spacing: VSpacing.xs) { Text("Provider") @@ -416,6 +490,7 @@ struct ProvidersSheet: View { private func beginCreate() { actionError = nil + isKeyDirty = false editorDraft = ConnectionDraft( provider: store.providerCatalog.first?.id ?? "" ) @@ -424,11 +499,14 @@ struct ProvidersSheet: View { private func beginEdit(_ conn: ProviderConnection) { actionError = nil + isKeyDirty = true editorDraft = ConnectionDraft( name: conn.name, + label: conn.label ?? "", provider: conn.provider, authType: conn.auth.type, - credential: conn.auth.credential ?? "" + credential: conn.auth.credential ?? "", + status: conn.status ) editorState = .edit(name: conn.name) } @@ -448,13 +526,17 @@ struct ProvidersSheet: View { ? draft.credential.trimmingCharacters(in: .whitespacesAndNewlines) : nil ) + let label: String? = draft.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : draft.label.trimmingCharacters(in: .whitespacesAndNewlines) + let status = draft.status switch editorState { case .create: guard let created = await client.createProviderConnection( name: name, provider: draft.provider, - auth: auth + auth: auth, + label: label, + status: status ) else { actionError = "Couldn't create connection. Please try again." return @@ -465,7 +547,9 @@ struct ProvidersSheet: View { case .edit(let originalName): guard let updated = await client.updateProviderConnection( name: originalName, - auth: auth + auth: auth, + status: status, + label: .some(label) ) else { await refresh() actionError = "Couldn't update connection. List refreshed." diff --git a/clients/macos/vellum-assistantTests/Features/Chat/ChatProfilePickerTests.swift b/clients/macos/vellum-assistantTests/Features/Chat/ChatProfilePickerTests.swift index 07b1fd0b0a6..c41de298867 100644 --- a/clients/macos/vellum-assistantTests/Features/Chat/ChatProfilePickerTests.swift +++ b/clients/macos/vellum-assistantTests/Features/Chat/ChatProfilePickerTests.swift @@ -50,6 +50,32 @@ final class ChatProfilePickerTests: XCTestCase { ) } + // MARK: - Disabled profiles are filtered from the picker + + func testDisabledProfilesAreFilteredFromLabel() { + let profiles: [InferenceProfile] = [ + InferenceProfile(name: "active-profile"), + InferenceProfile(name: "disabled-profile", status: "disabled"), + ] + // The picker's label helper uses the passed-in profiles directly; + // the filter happens inside the body at render time. + // Verify that isDisabled works correctly. + XCTAssertFalse(profiles[0].isDisabled) + XCTAssertTrue(profiles[1].isDisabled) + } + + func testPickerBodyFiltersDisabledProfiles() { + // Verify that the filtered activeProfiles excludes disabled ones. + let profiles: [InferenceProfile] = [ + InferenceProfile(name: "active-one"), + InferenceProfile(name: "disabled-one", status: "disabled"), + InferenceProfile(name: "active-two"), + ] + let active = profiles.filter { !$0.isDisabled } + XCTAssertEqual(active.count, 2) + XCTAssertEqual(active.map(\.name), ["active-one", "active-two"]) + } + // MARK: - Selection callback wiring (covers ComposerView → ChatProfilePicker → ConversationManager) func testConversationManagerSetsOverrideOnSelection() async { diff --git a/clients/macos/vellum-assistantTests/Features/Settings/InferenceProfileTests.swift b/clients/macos/vellum-assistantTests/Features/Settings/InferenceProfileTests.swift index 8c03505db16..cdfef0a59e1 100644 --- a/clients/macos/vellum-assistantTests/Features/Settings/InferenceProfileTests.swift +++ b/clients/macos/vellum-assistantTests/Features/Settings/InferenceProfileTests.swift @@ -293,4 +293,34 @@ final class InferenceProfileTests: XCTestCase { XCTAssertEqual(profile.temperature, .value(0.7)) XCTAssertEqual(profile.toJSON()["temperature"] as? Double, 0.7) } + + // MARK: - merging(_:) + + /// `setProfile`'s merge path (`SettingsStore.setProfile`) updates the + /// local cache via `merging(fragment)` after a partial-update PATCH + /// succeeds. A status flip in the fragment must propagate or the UI + /// will show the stale status until the next config refresh. + func testMergingAppliesStatusFromFragment() { + let base = InferenceProfile(name: "quality", status: nil, provider: "anthropic", model: "claude") + let fragment = InferenceProfile(name: "quality", status: "disabled") + + let merged = base.merging(fragment) + + XCTAssertEqual(merged.status, "disabled") + XCTAssertEqual(merged.provider, "anthropic") + XCTAssertEqual(merged.model, "claude") + } + + /// Fragments that omit `status` (e.g. only `model` changed) must + /// preserve the current status — same `if let` pattern every other + /// field uses. + func testMergingPreservesStatusWhenFragmentOmitsIt() { + let base = InferenceProfile(name: "quality", status: "disabled", provider: "anthropic") + let fragment = InferenceProfile(name: "quality", model: "claude-3.5") + + let merged = base.merging(fragment) + + XCTAssertEqual(merged.status, "disabled") + XCTAssertEqual(merged.model, "claude-3.5") + } } diff --git a/clients/macos/vellum-assistantTests/Features/Settings/ProvidersSheetTests.swift b/clients/macos/vellum-assistantTests/Features/Settings/ProvidersSheetTests.swift index be4519541a1..72a56dda790 100644 --- a/clients/macos/vellum-assistantTests/Features/Settings/ProvidersSheetTests.swift +++ b/clients/macos/vellum-assistantTests/Features/Settings/ProvidersSheetTests.swift @@ -39,12 +39,16 @@ final class ProvidersSheetTests: XCTestCase { private func makeConnection( name: String = "my-conn", provider: String = "anthropic", - authType: String = "api_key" + authType: String = "api_key", + status: ConnectionStatus = .active, + label: String? = nil ) -> ProviderConnection { ProviderConnection( name: name, provider: provider, auth: ProviderConnectionAuth(type: authType, credential: "sk-test"), + status: status, + label: label, createdAt: 0, updatedAt: 0 ) @@ -79,7 +83,9 @@ final class ProvidersSheetTests: XCTestCase { _ = await mockClient.createProviderConnection( name: "new-conn", provider: "openai", - auth: ProviderConnectionAuth(type: "api_key", credential: "sk-open") + auth: ProviderConnectionAuth(type: "api_key", credential: "sk-open"), + label: nil, + status: nil ) XCTAssertEqual(mockClient.createCallCount, 1) @@ -111,7 +117,9 @@ final class ProvidersSheetTests: XCTestCase { let result = await mockClient.updateProviderConnection( name: "gone", - auth: ProviderConnectionAuth(type: "api_key", credential: "sk-x") + auth: ProviderConnectionAuth(type: "api_key", credential: "sk-x"), + status: nil, + label: nil ) XCTAssertNil(result, "nil update signals 404; caller should refresh") @@ -135,4 +143,40 @@ final class ProvidersSheetTests: XCTestCase { let sheet = ProvidersSheet(store: store, isPresented: isPresented, client: mockClient) XCTAssertNotNil(sheet.body, "ProvidersSheet must build with the same store the card holds") } + + // MARK: - Display Name auto-derives Key via kebab-case + + func testLabelToKebabCaseAutoDerivation() { + // Verify toKebabCase produces correct output (shared with InferenceProfileEditor). + XCTAssertEqual(InferenceProfileEditor.toKebabCase("My OpenAI"), "my-openai") + XCTAssertEqual(InferenceProfileEditor.toKebabCase("Fast & Cheap"), "fast-cheap") + XCTAssertEqual(InferenceProfileEditor.toKebabCase(""), "") + XCTAssertEqual(InferenceProfileEditor.toKebabCase("hello world!"), "hello-world") + } + + // MARK: - Status toggle default + + func testNewConnectionDraftDefaultsToActiveStatus() { + let sheet = makeSheet() + // Verify the draft starts active; the sheet body builds without issues. + XCTAssertNotNil(sheet.body) + } + + // MARK: - Connections with label render correctly + + func testSheetBuildsWithLabeledConnection() { + let conn = makeConnection(name: "labeled", label: "My Anthropic") + mockClient.listResponse = [conn] + let sheet = makeSheet() + XCTAssertNotNil(sheet.body) + } + + // MARK: - Connections with disabled status + + func testSheetBuildsWithDisabledConnection() { + let conn = makeConnection(name: "disabled-conn", status: .disabled) + mockClient.listResponse = [conn] + let sheet = makeSheet() + XCTAssertNotNil(sheet.body) + } } diff --git a/clients/macos/vellum-assistantTests/Network/ProviderConnectionClientTests.swift b/clients/macos/vellum-assistantTests/Network/ProviderConnectionClientTests.swift index b5975dc8ec9..1abfe085175 100644 --- a/clients/macos/vellum-assistantTests/Network/ProviderConnectionClientTests.swift +++ b/clients/macos/vellum-assistantTests/Network/ProviderConnectionClientTests.swift @@ -36,13 +36,17 @@ final class MockProviderConnectionClient: ProviderConnectionClientProtocol { var createNameArg: String? var createProviderArg: String? var createAuthArg: ProviderConnectionAuth? + var createLabelArg: String? + var createStatusArg: ConnectionStatus? var createResponse: ProviderConnection? = nil - func createProviderConnection(name: String, provider: String, auth: ProviderConnectionAuth) async -> ProviderConnection? { + func createProviderConnection(name: String, provider: String, auth: ProviderConnectionAuth, label: String?, status: ConnectionStatus?) async -> ProviderConnection? { createCallCount += 1 createNameArg = name createProviderArg = provider createAuthArg = auth + createLabelArg = label + createStatusArg = status return createResponse } @@ -50,12 +54,16 @@ final class MockProviderConnectionClient: ProviderConnectionClientProtocol { var updateCallCount = 0 var updateNameArg: String? var updateAuthArg: ProviderConnectionAuth? + var updateStatusArg: ConnectionStatus? + var updateLabelArg: String?? var updateResponse: ProviderConnection? = nil - func updateProviderConnection(name: String, auth: ProviderConnectionAuth) async -> ProviderConnection? { + func updateProviderConnection(name: String, auth: ProviderConnectionAuth, status: ConnectionStatus?, label: String??) async -> ProviderConnection? { updateCallCount += 1 updateNameArg = name updateAuthArg = auth + updateStatusArg = status + updateLabelArg = label return updateResponse } @@ -77,12 +85,16 @@ private func makeConnection( name: String = "my-conn", provider: String = "anthropic", authType: String = "api_key", - credential: String? = "sk-test" + credential: String? = "sk-test", + status: ConnectionStatus = .active, + label: String? = nil ) -> ProviderConnection { ProviderConnection( name: name, provider: provider, auth: ProviderConnectionAuth(type: authType, credential: credential), + status: status, + label: label, createdAt: 0, updatedAt: 0 ) @@ -90,6 +102,59 @@ private func makeConnection( // MARK: - Tests +/// Verifies the generated `ProviderConnection` Codable conformance keeps the +/// macOS app working against daemons that predate the `status` field. Mixed- +/// version setups must default missing `status` to `.active` rather than +/// throwing `keyNotFound` and stranding the Providers UI. +final class ProviderConnectionDecodingTests: XCTestCase { + + private func decode(_ jsonString: String) throws -> ProviderConnection { + let data = jsonString.data(using: .utf8)! + return try JSONDecoder().decode(ProviderConnection.self, from: data) + } + + func testDecodesStatusWhenPresent() throws { + let conn = try decode(""" + { + "name": "my-conn", + "provider": "anthropic", + "auth": { "type": "api_key", "credential": "credential/anthropic/api_key" }, + "status": "disabled", + "createdAt": 1, + "updatedAt": 2 + } + """) + XCTAssertEqual(conn.status, .disabled) + } + + func testDefaultsToActiveWhenStatusKeyMissing() throws { + let conn = try decode(""" + { + "name": "my-conn", + "provider": "anthropic", + "auth": { "type": "api_key", "credential": "credential/anthropic/api_key" }, + "createdAt": 1, + "updatedAt": 2 + } + """) + XCTAssertEqual(conn.status, .active) + } + + func testDefaultsToActiveWhenStatusIsExplicitNull() throws { + let conn = try decode(""" + { + "name": "my-conn", + "provider": "anthropic", + "auth": { "type": "api_key", "credential": "credential/anthropic/api_key" }, + "status": null, + "createdAt": 1, + "updatedAt": 2 + } + """) + XCTAssertEqual(conn.status, .active) + } +} + @MainActor final class ProviderConnectionClientTests: XCTestCase { @@ -157,7 +222,7 @@ final class ProviderConnectionClientTests: XCTestCase { let conn = makeConnection(name: "new-conn") mock.createResponse = conn let auth = ProviderConnectionAuth(type: "api_key", credential: "sk-test") - let result = await mock.createProviderConnection(name: "new-conn", provider: "anthropic", auth: auth) + let result = await mock.createProviderConnection(name: "new-conn", provider: "anthropic", auth: auth, label: nil, status: nil) XCTAssertEqual(result?.name, "new-conn") XCTAssertEqual(mock.createNameArg, "new-conn") XCTAssertEqual(mock.createProviderArg, "anthropic") @@ -168,14 +233,14 @@ final class ProviderConnectionClientTests: XCTestCase { func testCreateReturnsNilOn409NameConflict() async { mock.createResponse = nil let auth = ProviderConnectionAuth(type: "api_key", credential: "sk-test") - let result = await mock.createProviderConnection(name: "existing", provider: "anthropic", auth: auth) + let result = await mock.createProviderConnection(name: "existing", provider: "anthropic", auth: auth, label: nil, status: nil) XCTAssertNil(result, "nil return models 409 name conflict") } func testCreateReturnsNilOn400InvalidAuth() async { mock.createResponse = nil let auth = ProviderConnectionAuth(type: "api_key", credential: "") - let result = await mock.createProviderConnection(name: "conn", provider: "anthropic", auth: auth) + let result = await mock.createProviderConnection(name: "conn", provider: "anthropic", auth: auth, label: nil, status: nil) XCTAssertNil(result, "nil return models 400 invalid auth") } @@ -185,7 +250,7 @@ final class ProviderConnectionClientTests: XCTestCase { let conn = makeConnection(name: "conn") mock.updateResponse = conn let auth = ProviderConnectionAuth(type: "api_key", credential: "sk-new") - let result = await mock.updateProviderConnection(name: "conn", auth: auth) + let result = await mock.updateProviderConnection(name: "conn", auth: auth, status: nil, label: nil) XCTAssertEqual(result?.name, "conn") XCTAssertEqual(mock.updateNameArg, "conn") XCTAssertEqual(mock.updateAuthArg?.credential, "sk-new") @@ -194,7 +259,7 @@ final class ProviderConnectionClientTests: XCTestCase { func testUpdateReturnsNilOn404() async { mock.updateResponse = nil let auth = ProviderConnectionAuth(type: "api_key", credential: "sk-x") - let result = await mock.updateProviderConnection(name: "missing", auth: auth) + let result = await mock.updateProviderConnection(name: "missing", auth: auth, status: nil, label: nil) XCTAssertNil(result) } diff --git a/clients/shared/Network/Generated/GeneratedAPITypes.swift b/clients/shared/Network/Generated/GeneratedAPITypes.swift index 43fa6ffdcb4..20428e7ba54 100644 --- a/clients/shared/Network/Generated/GeneratedAPITypes.swift +++ b/clients/shared/Network/Generated/GeneratedAPITypes.swift @@ -5970,22 +5970,49 @@ public struct ProviderConnectionAuth: Codable, Sendable { } } +/// Status of a provider connection. `active` (default) means the connection +/// is offered in picker UIs. `disabled` hides it from pickers but keeps it +/// visible in the settings sheet so the user can re-enable it. +public enum ConnectionStatus: String, Codable, Sendable { + case active + case disabled +} + /// A named provider connection stored in the assistant database. public struct ProviderConnection: Codable, Sendable { public let name: String /// One of: `anthropic`, `openai`, `gemini`, `ollama`, `fireworks`, `openrouter`. public let provider: String public let auth: ProviderConnectionAuth + public let status: ConnectionStatus + public let label: String? public let createdAt: Int public let updatedAt: Int - public init(name: String, provider: String, auth: ProviderConnectionAuth, createdAt: Int, updatedAt: Int) { + public init(name: String, provider: String, auth: ProviderConnectionAuth, status: ConnectionStatus = .active, label: String? = nil, createdAt: Int, updatedAt: Int) { self.name = name self.provider = provider self.auth = auth + self.status = status + self.label = label self.createdAt = createdAt self.updatedAt = updatedAt } + + /// Decodes responses from daemons that predate the `status` field by + /// defaulting to `.active`. Mixed-version setups (the app explicitly + /// supports them via version-mismatch handling) would otherwise throw + /// `keyNotFound` and silently strand the Providers UI. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.name = try container.decode(String.self, forKey: .name) + self.provider = try container.decode(String.self, forKey: .provider) + self.auth = try container.decode(ProviderConnectionAuth.self, forKey: .auth) + self.status = try container.decodeIfPresent(ConnectionStatus.self, forKey: .status) ?? .active + self.label = try container.decodeIfPresent(String.self, forKey: .label) + self.createdAt = try container.decode(Int.self, forKey: .createdAt) + self.updatedAt = try container.decode(Int.self, forKey: .updatedAt) + } } /// Response body for `GET /v1/inference/provider-connections`. @@ -6002,20 +6029,28 @@ public struct CreateProviderConnectionRequest: Codable, Sendable { public let name: String public let provider: String public let auth: ProviderConnectionAuth + public let label: String? + public let status: ConnectionStatus? - public init(name: String, provider: String, auth: ProviderConnectionAuth) { + public init(name: String, provider: String, auth: ProviderConnectionAuth, label: String? = nil, status: ConnectionStatus? = nil) { self.name = name self.provider = provider self.auth = auth + self.label = label + self.status = status } } /// Request body for `PATCH /v1/inference/provider-connections/:name`. public struct UpdateProviderConnectionRequest: Codable, Sendable { public let auth: ProviderConnectionAuth + public let status: ConnectionStatus? + public let label: String? - public init(auth: ProviderConnectionAuth) { + public init(auth: ProviderConnectionAuth, status: ConnectionStatus? = nil, label: String? = nil) { self.auth = auth + self.status = status + self.label = label } } diff --git a/clients/shared/Network/ProviderConnectionClient.swift b/clients/shared/Network/ProviderConnectionClient.swift index 22a573eca6e..3b103584a60 100644 --- a/clients/shared/Network/ProviderConnectionClient.swift +++ b/clients/shared/Network/ProviderConnectionClient.swift @@ -14,8 +14,10 @@ public enum ProviderConnectionDeleteResult: Sendable { public protocol ProviderConnectionClientProtocol { func listProviderConnections(provider: String?) async -> [ProviderConnection]? func getProviderConnection(name: String) async -> ProviderConnection? - func createProviderConnection(name: String, provider: String, auth: ProviderConnectionAuth) async -> ProviderConnection? - func updateProviderConnection(name: String, auth: ProviderConnectionAuth) async -> ProviderConnection? + /// `label` and `status` are optional extras; pass `nil` to omit from the request body. + func createProviderConnection(name: String, provider: String, auth: ProviderConnectionAuth, label: String?, status: ConnectionStatus?) async -> ProviderConnection? + /// `status`: nil = omit from body (no change). `label`: nil outer = omit; `.some(nil)` = send null (clear); `.some("v")` = set. + func updateProviderConnection(name: String, auth: ProviderConnectionAuth, status: ConnectionStatus?, label: String??) async -> ProviderConnection? func deleteProviderConnection(name: String) async -> ProviderConnectionDeleteResult } @@ -74,12 +76,20 @@ public struct ProviderConnectionClient: ProviderConnectionClientProtocol { } } - public func createProviderConnection(name: String, provider: String, auth: ProviderConnectionAuth) async -> ProviderConnection? { - let body: [String: Any] = [ + public func createProviderConnection( + name: String, + provider: String, + auth: ProviderConnectionAuth, + label: String? = nil, + status: ConnectionStatus? = nil + ) async -> ProviderConnection? { + var body: [String: Any] = [ "name": name, "provider": provider, "auth": Self.authDict(for: auth), ] + if let label { body["label"] = label } + if let status { body["status"] = status.rawValue } do { let response = try await GatewayHTTPClient.post( path: "inference/provider-connections", @@ -96,9 +106,18 @@ public struct ProviderConnectionClient: ProviderConnectionClientProtocol { } } - public func updateProviderConnection(name: String, auth: ProviderConnectionAuth) async -> ProviderConnection? { + public func updateProviderConnection( + name: String, + auth: ProviderConnectionAuth, + status: ConnectionStatus? = nil, + label: String?? = nil + ) async -> ProviderConnection? { let encoded = Self.encodePath(name) - let body: [String: Any] = ["auth": Self.authDict(for: auth)] + var body: [String: Any] = ["auth": Self.authDict(for: auth)] + if let status { body["status"] = status.rawValue } + if let outerLabel = label { + body["label"] = outerLabel ?? NSNull() + } do { let response = try await GatewayHTTPClient.patch( path: "inference/provider-connections/\(encoded)",