From 8b8ec137460ce658fdef7e664f516ba4da95a9a3 Mon Sep 17 00:00:00 2001 From: Vv Date: Tue, 22 Sep 2026 16:01:53 +0800 Subject: [PATCH 1/2] feat(portal): allow editing Azure AI Foundry model --- apps/portal/src/lib/api.ts | 2 +- apps/portal/src/pages/TokenDetail.test.ts | 40 +++++++++++ apps/portal/src/pages/TokenDetail.tsx | 37 ++++++++-- apps/portal/src/pages/token-detail-utils.ts | 27 +++++++ apps/portal/src/types.ts | 4 ++ apps/token-manager/src/foundry-model.test.ts | 62 ++++++++++++++++ apps/token-manager/src/foundry-model.ts | 40 +++++++++++ apps/token-manager/src/routes.ts | 75 +++++++++++++++++++- docs/architecture/token-manager.md | 9 ++- packages/shared/src/token-manager/types.ts | 9 ++- 10 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 apps/portal/src/pages/TokenDetail.test.ts create mode 100644 apps/portal/src/pages/token-detail-utils.ts create mode 100644 apps/token-manager/src/foundry-model.test.ts create mode 100644 apps/token-manager/src/foundry-model.ts diff --git a/apps/portal/src/lib/api.ts b/apps/portal/src/lib/api.ts index 912581db5..dda967e2c 100644 --- a/apps/portal/src/lib/api.ts +++ b/apps/portal/src/lib/api.ts @@ -851,7 +851,7 @@ export const api = { }); }, - /** Update key metadata (enabled, expiresAt) */ + /** Update key metadata and, for Azure AI Foundry keys, the model override */ updateKey: (id: string, body: UpdateKeyRequest): Promise => { return request(`/keys/${id}`, { method: "PUT", diff --git a/apps/portal/src/pages/TokenDetail.test.ts b/apps/portal/src/pages/TokenDetail.test.ts new file mode 100644 index 000000000..29813c82b --- /dev/null +++ b/apps/portal/src/pages/TokenDetail.test.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "vitest"; +import { buildKeyUpdateRequest } from "./token-detail-utils.js"; + +describe("buildKeyUpdateRequest", () => { + it("includes a trimmed model override for Azure AI Foundry keys", () => { + expect( + buildKeyUpdateRequest({ + type: "azure-ai-foundry", + enabled: true, + expiresAt: "", + comment: " production ", + foundryModel: " gpt-4.1-mini ", + }) + ).toEqual({ + enabled: true, + expiresAt: null, + comment: "production", + model: "gpt-4.1-mini", + }); + }); + + it("does not send a model field for other key types", () => { + expect( + buildKeyUpdateRequest({ + type: "github-oauth", + enabled: true, + expiresAt: "", + comment: "", + foundryModel: "ignored", + }) + ).toEqual({ + enabled: true, + expiresAt: null, + comment: null, + }); + }); +}); diff --git a/apps/portal/src/pages/TokenDetail.tsx b/apps/portal/src/pages/TokenDetail.tsx index f8894bba2..7e3c0317b 100644 --- a/apps/portal/src/pages/TokenDetail.tsx +++ b/apps/portal/src/pages/TokenDetail.tsx @@ -22,6 +22,7 @@ import { ArrowLeft, Trash2, ShieldCheck, Loader2, Save, Zap, Circle } from "luci import { formatDate } from "@/lib/utils"; import { toast } from "sonner"; import { useState, useEffect } from "react"; +import { buildKeyUpdateRequest } from "./token-detail-utils"; function statusVariant(status: string): "default" | "secondary" | "destructive" | "outline" { switch (status) { @@ -53,12 +54,14 @@ export function TokenDetail() { const [enabled, setEnabled] = useState(true); const [expiresAt, setExpiresAt] = useState(""); const [comment, setComment] = useState(""); + const [foundryModel, setFoundryModel] = useState(""); useEffect(() => { if (token) { setEnabled(token.enabled); setExpiresAt(token.expiresAt ? new Date(token.expiresAt).toISOString().slice(0, 16) : ""); setComment(token.comment ?? ""); + setFoundryModel(token.model ?? ""); } }, [token]); @@ -91,11 +94,13 @@ export function TokenDetail() { }); const handleSave = () => { - updateMutation.mutate({ + updateMutation.mutate(buildKeyUpdateRequest({ + type: token.type, enabled, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - comment: comment.trim() || null, - }); + expiresAt, + comment, + foundryModel, + })); }; if (isLoading) { @@ -338,6 +343,30 @@ export function TokenDetail() {

)} + {token.type === "azure-ai-foundry" && ( +
+ + {editing ? ( + <> + setFoundryModel(e.target.value)} + placeholder="e.g. gpt-4.1-mini" + /> +

+ Clear this field to use the default model. +

+ + ) : ( +

+ {token.model || ( + Provider default + )} +

+ )} +
+ )} diff --git a/apps/portal/src/pages/token-detail-utils.ts b/apps/portal/src/pages/token-detail-utils.ts new file mode 100644 index 000000000..f25357bb3 --- /dev/null +++ b/apps/portal/src/pages/token-detail-utils.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { KeyType, UpdateKeyRequest } from "../types.js"; + +export function buildKeyUpdateRequest({ + type, + enabled, + expiresAt, + comment, + foundryModel, +}: { + type: KeyType; + enabled: boolean; + expiresAt: string; + comment: string; + foundryModel: string; +}): UpdateKeyRequest { + return { + enabled, + expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + comment: comment.trim() || null, + ...(type === "azure-ai-foundry" + ? { model: foundryModel.trim() || null } + : {}), + }; +} diff --git a/apps/portal/src/types.ts b/apps/portal/src/types.ts index d0a7c1e97..c2499f10b 100644 --- a/apps/portal/src/types.ts +++ b/apps/portal/src/types.ts @@ -542,6 +542,8 @@ export interface KeyDocument { createdAt: string; updatedAt?: string; deletedAt?: string; + /** Non-secret Azure AI Foundry deployment / model name. */ + model?: string; } export interface KeyValidationResult { @@ -569,6 +571,8 @@ export interface UpdateKeyRequest { enabled?: boolean; expiresAt?: string | null; comment?: string | null; + /** Azure AI Foundry deployment / model name; null clears the override. */ + model?: string | null; } export const KEY_TYPE_LABELS: Record = { diff --git a/apps/token-manager/src/foundry-model.test.ts b/apps/token-manager/src/foundry-model.test.ts new file mode 100644 index 000000000..76a0c7a9b --- /dev/null +++ b/apps/token-manager/src/foundry-model.test.ts @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, vi } from "vitest"; +import type { KeyDocument } from "shared"; +import type { SecretStore } from "./keyvault-store.js"; +import { + updateFoundryModelSecret, + withFoundryModel, +} from "./foundry-model.js"; + +const token: KeyDocument = { + _id: "foundry-key", + type: "azure-ai-foundry", + capabilities: ["azure-ai-inference"], + secretName: "token-azure-ai-foundry-foundry", + enabled: true, + lastValidationStatus: "valid", + acquireCount: 0, + createdAt: new Date("2026-09-01T00:00:00Z"), +}; + +function makeStore() { + let value = JSON.stringify({ + endpoint: "https://example.services.ai.azure.com/models", + apiKey: "secret-api-key", + model: "old-model", + }); + return { + store: { + getSecret: vi.fn(async () => value), + setSecret: vi.fn(async (_name: string, next: string) => { + value = next; + }), + } as unknown as SecretStore, + getValue: () => value, + }; +} + +describe("Foundry model editing", () => { + it("projects the model without exposing endpoint or API key", async () => { + const { store } = makeStore(); + + const detail = await withFoundryModel(token, store); + + expect(detail).toMatchObject({ model: "old-model" }); + expect(detail).not.toHaveProperty("endpoint"); + expect(detail).not.toHaveProperty("apiKey"); + }); + + it("updates only the model and preserves the credential", async () => { + const { store, getValue } = makeStore(); + + await updateFoundryModelSecret(token, " new-model ", store); + + expect(JSON.parse(getValue())).toEqual({ + endpoint: "https://example.services.ai.azure.com/models", + apiKey: "secret-api-key", + model: "new-model", + }); + }); +}); diff --git a/apps/token-manager/src/foundry-model.ts b/apps/token-manager/src/foundry-model.ts new file mode 100644 index 000000000..71e8f67f4 --- /dev/null +++ b/apps/token-manager/src/foundry-model.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { KeyDocument } from "shared"; +import { parseAzureAiFoundrySecret } from "shared"; +import type { SecretStore } from "./keyvault-store.js"; + +/** Project only the non-secret model name into a single-key detail response. */ +export async function withFoundryModel( + token: KeyDocument, + store: SecretStore +): Promise { + if (token.type !== "azure-ai-foundry") { + return token; + } + + const parsed = parseAzureAiFoundrySecret( + await store.getSecret(token.secretName) + ); + return parsed?.model ? { ...token, model: parsed.model } : token; +} + +/** Rewrite only the model property while preserving the Foundry credential. */ +export async function updateFoundryModelSecret( + token: KeyDocument, + model: string | null, + store: SecretStore +): Promise { + const parsed = parseAzureAiFoundrySecret( + await store.getSecret(token.secretName) + ); + if (!parsed) return null; + + const value = JSON.stringify({ + ...parsed, + model: model?.trim() || undefined, + }); + await store.setSecret(token.secretName, value); + return value; +} diff --git a/apps/token-manager/src/routes.ts b/apps/token-manager/src/routes.ts index 7e866cac5..b27855add 100644 --- a/apps/token-manager/src/routes.ts +++ b/apps/token-manager/src/routes.ts @@ -17,6 +17,10 @@ import { import { SecretStore } from "./keyvault-store.js"; import { validateToken } from "./token-validators.js"; import { RoundRobinMap } from "./round-robin.js"; +import { + updateFoundryModelSecret, + withFoundryModel, +} from "./foundry-model.js"; const VALID_TYPES: KeyType[] = [ "github-pat-classic", @@ -184,14 +188,14 @@ export function createKeyRouter( return; } - res.json(token); + res.json(await withFoundryModel(token, store)); } catch (err) { next(err); } }); // ────────────────────────────────────────────── - // PUT /api/v1/keys/:id — Update metadata only + // PUT /api/v1/keys/:id — Update metadata and Foundry model override // ────────────────────────────────────────────── router.put("/api/v1/keys/:id", async (req, res, next) => { try { @@ -212,6 +216,46 @@ export function createKeyRouter( : null; } + let foundrySecretValue: string | undefined; + if (body.model !== undefined) { + if (body.model !== null && typeof body.model !== "string") { + res.status(400).json({ error: "model must be a string or null" }); + return; + } + + const token = await collection.findOne({ + _id: req.params.id, + deletedAt: { $exists: false }, + }); + + if (!token) { + res.status(404).json({ error: "Key not found" }); + return; + } + if (token.type !== "azure-ai-foundry") { + res.status(400).json({ + error: "model can only be updated for Azure AI Foundry keys", + }); + return; + } + + const updatedFoundrySecret = await updateFoundryModelSecret( + token, + body.model, + store + ); + if (!updatedFoundrySecret) { + res.status(422).json({ error: "Stored Azure AI Foundry key is invalid" }); + return; + } + foundrySecretValue = updatedFoundrySecret; + + // The deployment is part of validation, so the previous result is stale. + update.lastValidationStatus = "unknown"; + update.lastValidatedAt = null; + update.lastValidationError = null; + } + const result = await collection.findOneAndUpdate( { _id: req.params.id, deletedAt: { $exists: false } }, { $set: update }, @@ -223,7 +267,32 @@ export function createKeyRouter( return; } - res.json(result); + if (foundrySecretValue) { + validateToken(result.type, foundrySecretValue) + .then(async (validation) => { + const now = new Date(); + await collection.updateOne( + { _id: result._id }, + { + $set: { + lastValidatedAt: now, + lastValidationStatus: validation.status, + lastValidationError: validation.error ?? undefined, + capabilities: validation.capabilities ?? [], + updatedAt: now, + }, + } + ); + }) + .catch((err) => { + console.error( + `[routes] Background validation failed for ${result._id}:`, + err + ); + }); + } + + res.json(await withFoundryModel(result, store)); } catch (err) { next(err); } diff --git a/docs/architecture/token-manager.md b/docs/architecture/token-manager.md index 5ea8dc963..28d659b1f 100644 --- a/docs/architecture/token-manager.md +++ b/docs/architecture/token-manager.md @@ -153,7 +153,8 @@ stateDiagram-v2 | `GET` | `/api/v1/keys` | List all keys (optionally filter by capability) | | `POST` | `/api/v1/keys` | Register a new key | | `POST` | `/api/v1/keys/preview` | Preview capabilities without registering | -| `GET` | `/api/v1/keys/:id` | Get key details (excludes secret) | +| `GET` | `/api/v1/keys/:id` | Get key details (excludes secrets; includes the non-secret Foundry model name) | +| `PUT` | `/api/v1/keys/:id` | Update metadata and the optional Azure AI Foundry model override | | `DELETE` | `/api/v1/keys/:id` | Delete a key | | `POST` | `/api/v1/keys/:id/validate` | Trigger manual validation | @@ -165,6 +166,12 @@ stateDiagram-v2 The acquire endpoint uses round-robin selection among valid, enabled keys that provide the requested capability. +Updating the model override rewrites only the `model` property of the Foundry +credential stored in Key Vault; the endpoint and API key are preserved. The +key returns to `unknown` while the Token Manager validates the new deployment +in the background. The Portal polls the detail endpoint until that validation +finishes. + ## Usage Tracking Each key tracks: diff --git a/packages/shared/src/token-manager/types.ts b/packages/shared/src/token-manager/types.ts index aeea6f7e6..756e0b479 100644 --- a/packages/shared/src/token-manager/types.ts +++ b/packages/shared/src/token-manager/types.ts @@ -55,6 +55,11 @@ export interface KeyDocument { createdAt: Date; updatedAt?: Date; deletedAt?: Date; + /** + * Azure AI Foundry deployment / model name. This non-secret field is + * projected from KeyVault only on the single-key detail response. + */ + model?: string; } /** @@ -99,12 +104,14 @@ export interface CreateKeyRequest { /** * Request body for PUT /api/v1/keys/:id. - * Only metadata — secret value is immutable. + * Metadata plus the non-secret Azure AI Foundry model override. */ export interface UpdateKeyRequest { enabled?: boolean; expiresAt?: string | null; comment?: string | null; + /** Azure AI Foundry deployment / model name; null clears the override. */ + model?: string | null; } /** From a33849ac933cdb56f4b15ca2567b8d9b98c2ecbf Mon Sep 17 00:00:00 2001 From: Vv Date: Tue, 22 Sep 2026 18:51:56 +0800 Subject: [PATCH 2/2] test(portal): add model editing story --- apps/portal/src/pages/TokenDetail.stories.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 apps/portal/src/pages/TokenDetail.stories.tsx diff --git a/apps/portal/src/pages/TokenDetail.stories.tsx b/apps/portal/src/pages/TokenDetail.stories.tsx new file mode 100644 index 000000000..53365a8f1 --- /dev/null +++ b/apps/portal/src/pages/TokenDetail.stories.tsx @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { Route, Routes, useNavigate } from "react-router-dom"; + +import { TokenDetail } from "./TokenDetail"; +import type { KeyDocument, UpdateKeyRequest } from "@/types"; + +const TOKEN_ID = "demo-foundry-key"; +let currentModel = "gpt-4.1"; +let validationReadyAt = 0; + +function makeToken(): KeyDocument { + const validationComplete = Date.now() >= validationReadyAt; + + return { + _id: TOKEN_ID, + type: "azure-ai-foundry", + capabilities: ["azure-ai-inference"], + secretName: "demo-foundry-key", + lastValidationStatus: validationComplete ? "valid" : "unknown", + lastValidatedAt: validationComplete ? "2026-09-22T08:00:00.000Z" : undefined, + enabled: true, + comment: "Synthetic key for the model-editing demo", + acquireCount: 3, + createdAt: "2026-09-22T07:30:00.000Z", + updatedAt: "2026-09-22T08:00:00.000Z", + model: currentModel, + }; +} + +function TokenDetailDemo() { + const navigate = useNavigate(); + + useEffect(() => { + navigate(`/secrets/keys/${TOKEN_ID}`, { replace: true }); + }, [navigate]); + + return ( +
+ + } /> + Loading demo…

} /> +
+
+ ); +} + +const meta = { + title: "Pages/TokenDetail", + component: TokenDetailDemo, + parameters: { + layout: "fullscreen", + msw: { + handlers: [ + http.get(`/api/v1/keys/${TOKEN_ID}`, () => HttpResponse.json(makeToken())), + http.put(`/api/v1/keys/${TOKEN_ID}`, async ({ request }) => { + const body = await request.json() as UpdateKeyRequest; + if (body.model !== undefined) { + currentModel = body.model ?? ""; + validationReadyAt = Date.now() + 1500; + } + return HttpResponse.json(makeToken()); + }), + ], + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AzureAiFoundryModelEdit: Story = {};