Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/portal/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KeyDocument> => {
return request(`/keys/${id}`, {
method: "PUT",
Expand Down
76 changes: 76 additions & 0 deletions apps/portal/src/pages/TokenDetail.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen bg-background p-8">
<Routes>
<Route path="/secrets/keys/:id" element={<TokenDetail />} />
<Route path="*" element={<p className="text-sm text-muted-foreground">Loading demo…</p>} />
</Routes>
</div>
);
}

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<typeof TokenDetailDemo>;

export default meta;
type Story = StoryObj<typeof meta>;

export const AzureAiFoundryModelEdit: Story = {};
40 changes: 40 additions & 0 deletions apps/portal/src/pages/TokenDetail.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
37 changes: 33 additions & 4 deletions apps/portal/src/pages/TokenDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -338,6 +343,30 @@ export function TokenDetail() {
</p>
)}
</div>
{token.type === "azure-ai-foundry" && (
<div className="space-y-1">
<Label htmlFor="foundry-model">Deployment / Model name</Label>
{editing ? (
<>
<Input
id="foundry-model"
value={foundryModel}
onChange={(e) => setFoundryModel(e.target.value)}
placeholder="e.g. gpt-4.1-mini"
/>
<p className="text-xs text-muted-foreground">
Clear this field to use the default model.
</p>
</>
) : (
<p className="text-sm">
{token.model || (
<span className="text-muted-foreground">Provider default</span>
)}
</p>
)}
</div>
)}
</div>
</CardContent>
</Card>
Expand Down
27 changes: 27 additions & 0 deletions apps/portal/src/pages/token-detail-utils.ts
Original file line number Diff line number Diff line change
@@ -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 }
: {}),
};
}
4 changes: 4 additions & 0 deletions apps/portal/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<KeyType, string> = {
Expand Down
62 changes: 62 additions & 0 deletions apps/token-manager/src/foundry-model.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});
40 changes: 40 additions & 0 deletions apps/token-manager/src/foundry-model.ts
Original file line number Diff line number Diff line change
@@ -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<KeyDocument> {
if (token.type !== "azure-ai-foundry") {
return token;
}

const parsed = parseAzureAiFoundrySecret(
await store.getSecret(token.secretName)
);
return parsed?.model ? { ...token, model: parsed.model } : token;
Comment on lines +17 to +20
}

/** Rewrite only the model property while preserving the Foundry credential. */
export async function updateFoundryModelSecret(
token: KeyDocument,
model: string | null,
store: SecretStore
): Promise<string | null> {
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;
}
Loading
Loading