Compare commits

..
Author SHA1 Message Date
dayuan.jiang 13371e8092 ci: run the checks on the rebased branch 2026-10-12 21:22:35 +09:00
dayuan.jiang 55a32af76a fix(security): keep EdgeOne cookies and the access code on this site
Two issues from before the provider refactor, found by its review.

The redirect guard dropped Authorization, cookies and the providers' key
headers when a redirect left the origin, but kept x-access-code, this site's
own access code; it is dropped too now.

edgeOneEndpoint took the request's Origin header as the site's address.
Behind a proxy the request URL is the internal one and Origin is the site's,
so Origin still wins, but only when its host matches Host or
X-Forwarded-Host; a request carrying another site's Origin now gets the
request's own origin, so the EdgeOne cookies and the access code never travel
to that site.
2026-10-12 21:17:51 +09:00
dayuan.jiang 75ffc32a49 fix(providers): apply the review of PR #988
Five reviewers (Fable 5.1, Opus 5.5, Sonnet 5.5, Codex GPT-6 Astra, Codex
GPT-6.1 Sol) found no behavior or security regression; this commit applies
what they asked for.

Settings dialog: the automatic model list fetch only fills the list, which
waits behind 'Browse models'; it no longer opens the picker (the modal picker
took the focus from whatever the user was typing) and shows no error of its
own. One list request per provider at a time, so a click during the automatic
fetch sends nothing twice; the baseline that detects freshly entered
credentials is taken on every visit of a provider page; SGLang's prefilled
local example URL triggers no fetch. Entries saved with Azure's old
placeholder URL still count as having no URL. The base URL field of Azure and
of the custom preset is labelled required (new dictionary key baseUrlRequired).

Smaller code: the PROVIDER_INFO alias is gone (three files and the tests
import PROVIDERS), as are three logo values no code read, two unused type
re-exports, three unused exports and SINGLE_SYSTEM_PROVIDERS (the chat route
reads the record). Renamed for clarity: REQUIRED_KEY_VARS, requiredKeyEnv,
configuredUrl, LocalizedText. Seven stale comments rewritten; the custom
preset's note shortened.

Tests: the matrix gains six override shapes (a Vertex key alone, AWS keys with
a session token, headers with a URL, a trusted URL with a key, a key with a
server model's URL variable, '/' alone), 5843 rows; the new rows were
regenerated on the pre-refactor code and match it. The matrix and the server
credential tests clear every variable they read; the admin variable table
covers every provider; a test that derived its expectation from the function
under test is gone; the singleSystem, listNeedsNoKey, sdkKnowsEndpoint and
auto-detection sets are spelled out.

Docs: the READMEs say 25 providers and name the OpenAI-compatible preset; the
AI_PROVIDER lists are complete; the Azure example URL has its /openai suffix;
'Adding a Provider' names the tests to update.
2026-10-12 21:17:51 +09:00
dayuan.jiang 65625ba212 docs: record how the provider refactor plan was carried out
Steps 0 to 8 are on this branch; the record lists where the implementation
departed from the plan (steps 1 and 2 merged, Azure's placeholder removed in
step 5, a narrower trigger for the automatic model list, the snapshot's
sixteenth shape, line counts) and the checks that were run.
2026-10-12 21:17:51 +09:00
dayuan.jiang b2cadfa5bf feat(providers): an OpenAI-compatible preset for any vLLM, LM Studio or gateway URL
Step 8 of the provider refactor plan, a new provider made of one record.
'custom' runs on @ai-sdk/openai-compatible with <think> extraction, merges
system messages (open-source chat templates reject several), has no default
URL (so a key alone does not count as connected, and the chat endpoint is
shown under the URL field) and lists models from its /models. Server side:
CUSTOM_API_KEY and CUSTOM_BASE_URL, ADMIN_CUSTOM_* in the admin panel.
env.example and the three provider docs gained a section; the picker count
in the e2e test is 25 and a new e2e test walks through the preset. The
behavior snapshot gained the new provider's 146 rows; its two 'No AI provider
configured' rows list CUSTOM_API_KEY now.
2026-10-12 21:17:51 +09:00
dayuan.jiang 717f7e9445 feat(settings): providers that can list their models fetch the list instead of a hand-written one
Step 7 of the provider refactor plan, an intended UI behavior change. The
hand-written SUGGESTED_MODELS table (179 ids for 19 providers) is gone. The
six providers that cannot list their models online (Bedrock, Vertex AI,
Azure, EdgeOne, Doubao, MiniMax) keep a short list on their record, still
get its first model when added, and still show quick-add chips and the admin
panel's dropdown. For the other 18 the settings dialog fetches the provider's
own list once the user has entered credentials on the page while it has no
model yet (600 ms after the typing pauses), and opens the picker with the
result; the 'Get all models' button and the model id input work as before.
Credentials a provider already had when its page opened fetch nothing by
themselves. The admin panel shows the 'suggested' dropdown only for the six.

Two e2e tests follow the change: the first-provider flow mocks the list
endpoint and picks the model from the list; the empty-list test checks that a
new key fetches again and opens the list once it has models.
2026-10-12 21:17:51 +09:00
dayuan.jiang e1c083dae0 refactor(providers): drop the transitional tables, document how to add a provider
Step 6 of the provider refactor plan. FIXED_CRED_PROVIDERS, PROVIDER_LOGO_MAP,
OPTIONS_OPEN and NO_KEY_NEEDED had no readers left and are gone; the record's
fields are read where they were used. The fixture that compared the derived
tables with their old literal values is gone with them; tests/unit/providers.test.ts
keeps the record's own rules (21 providers in auto-detection, every note in
every UI language, https defaults only, fixed-credential providers, the
picker's order) and a new check that env.example names every record's
variables. docs/{en,cn,ja}/ai-providers.md end with a section on adding a
provider: one record, two env.example lines, one docs paragraph.
2026-10-12 21:17:51 +09:00
dayuan.jiang f811242568 refactor(providers): the UI, the connection test and the routes read the record's fields
Step 5 of the provider refactor plan. Provider names gave way to the
record's credential and sdk fields in: hasCredentials (one rule per
credential kind), the connection test (an Object.hasOwn check first, then a
credential switch; EdgeOne's site endpoint and cookie forwarding follow
credential none), the chat route (the on-site function and the single-system
rule for the OpenAI SDK), the credentials form (which fields to show, the
optional-key label, the base URL hint from the record), the picker (the note
from the record), the logo (icon and logo fields), the settings dialog (test
blocker, error hints, EdgeOne origin), the admin panel (fixedCredentials),
chatRequestUrl (sdk), the model list (sdk aihubmix) and the zod schema
(Object.hasOwn instead of 'in'). The eight provider-specific dictionary
entries moved into the record as four-language objects; needAzure became a
generic needUrl.

Two intended behavior changes: the connection test answers 400 'Invalid
provider' for an unknown name whether or not a key is sent (before: 400
'API key is required' without a key, 200 with an error text with one); and
Azure has no placeholder default URL any more, so a new Azure entry starts
with an empty URL and the generic 'needs a URL' rule applies. The e2e
Azure test seeds no URL and expects the new wording. Out-of-layer
provider-name checks: 44 before, 6 after (the admin panel's fixed-variable
branches for Bedrock, Vertex AI and Ollama, kept on purpose). Behavior
snapshot unchanged.
2026-10-12 21:17:51 +09:00
dayuan.jiang e0a196dded refactor(providers): the quota functions and the model list fallback read the record
Step 4c of the provider refactor plan. usesServerCredentials asks
resolveEndpoint whose key the call uses, so the 'mirrors each branch of
getAIModel' comment and its switch are gone. usesServerEndpoint reads the
credential kind (none, optionalKey) and the sdk (bedrock) instead of three
provider names. listFallbackUrl reads the credential kind and
publicDefaultUrl. Before this commit the differential test agreed with the
pre-refactor copies on all 30720 and 288 input classes; the copies are
deleted now, and the behavior snapshot keeps both functions' answers for
every recorded scenario.
2026-10-12 21:17:51 +09:00
dayuan.jiang 841efcfea2 test: record a private client URL in the provider behavior snapshot
A sixteenth override shape, a key with a private base URL, so the snapshot
holds usesServerEndpoint's answer for the server's own network before the
function changes. 252 rows added, none changed.
2026-10-12 21:17:51 +09:00
dayuan.jiang 25a1059648 refactor(providers): resolveEndpoint decides key and URL; getAIModel loses its second switch
Steps 4a and 4b of the provider refactor plan. resolveEndpoint(provider,
overrides) answers, by the record's credential kind, whose key a call uses
(keySource), which key, which URL, whether the URL was configured and whether
the client-chosen URL needs the redirect guard. It never throws, fetches or
logs. The five branches of getAIModel's second switch (bedrock, vertexai,
ollama, edgeone, default) are gone: Bedrock's credential selection moved
unchanged into bedrockModel(), called when the record's sdk is bedrock; every
other provider goes through resolveEndpoint and createModel, whose switch now
runs on the record's sdk field (Vertex and Ollama became cases). The three
errors for a missing Vertex key or a missing base URL stay in getAIModel with
their texts. The SSRF guard at the top reads the credential kind instead of
provider names. resolveApiKey returns whose key it found; the array apiKeyEnv
log moved to getAIModel so a later usesServerCredentials call draws no second
log line.

The behavior snapshot is unchanged, and resolveEndpoint's keySource agrees
with the pre-refactor usesServerCredentials on all 30720 input classes.
2026-10-12 21:17:51 +09:00
dayuan.jiang 932fd03a7c refactor(providers): one place for env var names and the server credential check
Step 3 of the provider refactor plan. envVarsOf(provider) spells the key and
base URL variable names from the record's envPrefix and keyEnv; the three
copies of the gateway/AI_GATEWAY spelling (getAIModel's default branch,
globalBaseUrl, the admin panel's credEnvNames) read it. serverCredentialError
answers why the server lacks a provider's credentials, or null; detectProvider
asks for null and validateProviderCredentials throws the text, so the
Anthropic auth token and the Azure URL-or-resource rules exist once. The four
error texts and their order are unchanged (tests/unit/server-credentials.test.ts
was written against the old code first). credEnvNames reads fixedCredentials
and envPrefix and is exported, with a table test over all 24 providers.
Behavior snapshot unchanged.
2026-10-12 21:17:51 +09:00
dayuan.jiang b0e95a5ef4 refactor(providers): one record per provider, ten name-keyed tables derived from it
Step 2 of the provider refactor plan. lib/providers.ts holds one record per
provider (SDK kind, credential kind, env prefix, default URL, picker group,
notes and base URL hints in four languages, and the short model list of the
six providers that cannot list models). ProviderName is now the record's
key set and lib/types/model-config.ts re-exports the record as PROVIDER_INFO,
so the twelve importers are unchanged.

Derived from the record, under their old names and in their old files:
FIXED_CRED_PROVIDERS, PROVIDER_LOGO_MAP and chatRequestUrl's own-path list
(model-config.ts), SINGLE_SYSTEM_PROVIDERS, PROVIDER_ENV_VARS and
SDK_KNOWS_ENDPOINT (ai-providers.ts), PROVIDER_GROUPS and OPTIONS_OPEN
(provider-setup.ts), NO_KEY_NEEDED (provider-models route).
tests/unit/providers.test.ts freezes the old tables' values and checks the
derived ones against them. The record is written in the picker's order, so
the provider lists in two 'No AI provider configured' messages change order;
those two snapshot rows are the only snapshot change.
2026-10-12 21:17:51 +09:00
dayuan.jiang 31f4641178 test: record the provider layer's behavior before the refactor
Step 0 of docs/plans/2026-10-provider-refactor.md. provider-matrix.test.ts
snapshots what getAIModel hands to each SDK factory, and what
usesServerCredentials, usesServerEndpoint, globalBaseUrl and chatRequestUrl
answer, for 24 providers, 15 client override shapes and 9 server
environments, plus auto-detection rows and model id probes (3879 rows).
provider-legacy.test.ts keeps a copy of the two quota functions and compares
them with the live ones over every input class (30720 and 288 combinations).
No production code changes.
2026-10-12 21:17:51 +09:00
dayuan.jiang 2aef9d180c docs: re-verify the provider refactor plan against main 99ced88 and fold in the owner's decisions 2026-10-12 21:17:51 +09:00
dayuan.jiang 9c31481dd7 docs: revise the provider refactor plan after the multi-model review 2026-10-12 21:17:51 +09:00
dayuan.jiang 1ef9f3ea3d docs: add the AI provider layer refactor plan 2026-10-12 21:17:51 +09:00
4 changed files with 24 additions and 13 deletions
-3
View File
@@ -9,9 +9,6 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
# Run it by hand on main when a push did not trigger it (GitHub drops an
# event now and then); it then pushes to ECR like a push to main
workflow_dispatch:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
+17 -4
View File
@@ -11,7 +11,7 @@ const MCP_URL =
"https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server" "https://github.com/DayuanJiang/next-ai-draw-io/tree/main/packages/mcp-server"
/** /**
* Whether the user closed the MCP notice once. Starts as closed so the server * Whether the user closed the MCP video once. Starts as closed so the server
* render and the first client render agree; the stored choice is read after * render and the first client render agree; the stored choice is read after
* mount. * mount.
*/ */
@@ -90,12 +90,25 @@ const cardClass =
"w-full rounded-xl border border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-blue-500/10" "w-full rounded-xl border border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-blue-500/10"
/** /**
* MCP server notice on the start screen: the card with the promo video. Once * MCP server notice on the start screen. Until the user closes it, the card
* the user closes it, it does not come back. * also plays the promo video; after that it is the plain card for good.
*/ */
export function McpNotice({ className }: { className?: string }) { export function McpNotice({ className }: { className?: string }) {
const [closed, close] = useVideoClosed() const [closed, close] = useVideoClosed()
if (closed) return null if (closed) {
return (
<div
className={cn(
cardClass,
"px-4 py-3 transition-colors hover:border-purple-500/40",
className,
)}
data-testid="mcp-notice"
>
<NoticeLink />
</div>
)
}
return ( return (
<div <div
className={cn(cardClass, "p-3", className)} className={cn(cardClass, "p-3", className)}
+1 -1
View File
@@ -30,6 +30,6 @@ export const STORAGE_KEYS = {
// Langfuse session id // Langfuse session id
sessionId: "next-ai-draw-io-session-id", sessionId: "next-ai-draw-io-session-id",
// The start screen's MCP card with its video was closed (it does not come back) // The start screen's MCP video was closed (it does not come back)
mcpVideoClosed: "next-ai-draw-io-mcp-video-closed", mcpVideoClosed: "next-ai-draw-io-mcp-video-closed",
} as const } as const
+6 -5
View File
@@ -170,17 +170,18 @@ test.describe("Workspace", () => {
await expect(getChatInput(page)).toHaveValue("Draw a cat for me") await expect(getChatInput(page)).toHaveValue("Draw a cat for me")
}) })
test("the MCP card and its video stay away once closed", async ({ test("the MCP card's video stays away once closed", async ({ page }) => {
page,
}) => {
await openApp(page) await openApp(page)
const card = page.locator('[data-testid="mcp-notice"]') const card = page.locator('[data-testid="mcp-notice"]')
const video = page.locator('[data-testid="mcp-video"]')
await expect(card.locator('[data-testid="mcp-video"]')).toBeVisible() await expect(card.locator('[data-testid="mcp-video"]')).toBeVisible()
await page.locator('[data-testid="mcp-video-close"]').click() await page.locator('[data-testid="mcp-video-close"]').click()
await expect(card).toHaveCount(0) await expect(video).toHaveCount(0)
await expect(card).toBeVisible()
await page.reload({ waitUntil: "networkidle" }) await page.reload({ waitUntil: "networkidle" })
await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible() await expect(page.locator('[data-testid="lobby-hero"]')).toBeVisible()
await expect(card).toHaveCount(0) await expect(card).toBeVisible()
await expect(video).toHaveCount(0)
}) })
test("each AI change becomes a version card; the latest undoes and redoes it", async ({ test("each AI change becomes a version card; the latest undoes and redoes it", async ({