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 = {};