feat(ui): oauth_credential_select field type for Add Model form

Previously the ChatGPT (OAuth) provider in the Add Model form rendered
a plain text input for ``api_key`` and expected the admin to type the
``oauth:<credential_name>`` marker by hand — easy to typo, and
discoverability depended on reading the tooltip.

Add a new ``field_type: "oauth_credential_select"`` that
``provider_specific_fields.tsx`` renders as an antd Select populated
from ``useCredentials()``, filtered to OAuth-backed credentials
(``credential_info.type`` in ``{chatgpt_oauth, copilot_oauth}``).
Picking a row stores ``oauth:<credential_name>`` as the form value, so
the downstream request path is unchanged.

Wire-up:

- ``ProviderCredentialField`` Literal (Python) and
  ``ProviderCredentialFieldMetadata`` union (TypeScript) both extended
  with the new type.
- ``provider_create_fields.json`` for ``ChatGPT`` updated to use it.
- Empty-state: if no OAuth credentials exist yet, the dropdown shows a
  "No OAuth credentials found" hint pointing at Credentials → Add
  Credential.

No change to Copilot's entry — Copilot's existing ``api_key``/``api_base``
text fields stay, since that provider still supports plain PAT auth
alongside OAuth. Admins who want the OAuth flow for Copilot can still
type ``oauth:<name>`` into the ``api_key`` field manually; the dropdown
will cover that too in a follow-up if you want symmetry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jason Cook 2026-04-23 11:21:02 -04:00
parent a5225003a3
commit 9d0466ba6d
4 changed files with 67 additions and 10 deletions

View file

@ -577,13 +577,13 @@
"credential_fields": [
{
"key": "api_key",
"label": "OAuth credential reference",
"placeholder": "oauth:my-chatgpt",
"tooltip": "Reference a stored ChatGPT OAuth credential by name, prefixed with 'oauth:'. Create the credential first via Credentials \u2192 Add Credential \u2192 ChatGPT (OAuth).",
"label": "OAuth credential",
"placeholder": "Select a stored ChatGPT OAuth credential",
"tooltip": "Pick a credential you signed in with via Credentials \u2192 Add Credential \u2192 ChatGPT (OAuth). The proxy resolves the chosen name to the stored OAuth tokens at request time.",
"required": true,
"field_type": "text",
"field_type": "oauth_credential_select",
"options": null,
"default_value": "oauth:"
"default_value": null
}
],
"default_model_placeholder": "gpt-5.3-codex"

View file

@ -19,7 +19,14 @@ class ProviderCredentialField(BaseModel):
placeholder: Optional[str] = None
tooltip: Optional[str] = None
required: bool = False
field_type: Literal["text", "password", "select", "upload", "textarea"] = "text"
field_type: Literal[
"text",
"password",
"select",
"upload",
"textarea",
"oauth_credential_select",
] = "text"
options: Optional[List[str]] = None
default_value: Optional[str] = None

View file

@ -1,3 +1,4 @@
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields";
import { UploadOutlined } from "@ant-design/icons";
import { Text, TextInput } from "@tremor/react";
@ -5,6 +6,10 @@ import { Button as Button2, Col, Form, Input, Row, Select, Typography, Upload, U
import React from "react";
import { CredentialItem, ProviderCredentialFieldMetadata } from "../networking";
import { provider_map, Providers } from "../provider_info_helpers";
// credential_info.type values recognised as OAuth-backed credentials.
// Add more as we ship OAuth flows for other providers.
const OAUTH_CREDENTIAL_TYPES = new Set(["chatgpt_oauth", "copilot_oauth"]);
const { Link } = Typography;
interface ProviderSpecificFieldsProps {
@ -18,7 +23,13 @@ interface ProviderCredentialField {
placeholder?: string;
tooltip?: string;
required?: boolean;
type?: "text" | "password" | "select" | "upload" | "textarea";
type?:
| "text"
| "password"
| "select"
| "upload"
| "textarea"
| "oauth_credential_select";
options?: string[];
defaultValue?: string;
}
@ -38,7 +49,9 @@ const mapFieldMetadataToUiField = (field: ProviderCredentialFieldMetadata): Prov
? "upload"
: field.field_type === "textarea"
? "textarea"
: "text";
: field.field_type === "oauth_credential_select"
? "oauth_credential_select"
: "text";
return {
key: field.key,
@ -98,6 +111,17 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
const { data: providerMetadata, isLoading, error: loadError } = useProviderFields();
// Fetched lazily; only used by oauth_credential_select fields. The hook
// is a no-op until ``accessToken`` is available.
const { data: credentialsResponse } = useCredentials();
const oauthCredentials = React.useMemo(() => {
const all = credentialsResponse?.credentials ?? [];
return all.filter((c: CredentialItem) => {
const type = (c.credential_info as Record<string, unknown> | undefined)?.type;
return typeof type === "string" && OAUTH_CREDENTIAL_TYPES.has(type);
});
}, [credentialsResponse]);
// Memoize the expensive cache computation
const cacheEntries = React.useMemo(() => {
if (!providerMetadata) {
@ -223,7 +247,27 @@ const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({ selecte
tooltip={field.tooltip}
className={field.key === "vertex_credentials" ? "mb-0" : undefined}
>
{field.type === "select" ? (
{field.type === "oauth_credential_select" ? (
<Select
placeholder={field.placeholder ?? "Select a stored OAuth credential"}
showSearch
notFoundContent={
<Text className="text-sm text-gray-500">
No OAuth credentials found. Create one via Credentials →
Add Credential first.
</Text>
}
>
{oauthCredentials.map((c) => (
<Select.Option
key={c.credential_name}
value={`oauth:${c.credential_name}`}
>
{c.credential_name}
</Select.Option>
))}
</Select>
) : field.type === "select" ? (
<Select placeholder={field.placeholder} defaultValue={field.defaultValue}>
{field.options?.map((option) => (
<Select.Option key={option} value={option}>

View file

@ -272,7 +272,13 @@ export interface ProviderCredentialFieldMetadata {
placeholder?: string | null;
tooltip?: string | null;
required?: boolean;
field_type?: "text" | "password" | "select" | "upload" | "textarea";
field_type?:
| "text"
| "password"
| "select"
| "upload"
| "textarea"
| "oauth_credential_select";
options?: string[] | null;
default_value?: string | null;
}