diff --git a/server/api/src/__tests__/agents/core/composeBackendValidation.test.ts b/server/api/src/__tests__/agents/core/composeBackendValidation.test.ts index 5595cbcc..370390a5 100644 --- a/server/api/src/__tests__/agents/core/composeBackendValidation.test.ts +++ b/server/api/src/__tests__/agents/core/composeBackendValidation.test.ts @@ -27,14 +27,38 @@ describe("assertComposeBackendReady", () => { expect(mockGetUserAiCredentialPlaintext).not.toHaveBeenCalled(); }); - it("allows BYOK backend without static env model provider mismatch (#972)", async () => { + it("rejects non-Google BYOK for wiki-compose-research (pinned Google model)", async () => { + await expect( + assertComposeBackendReady({ + backend: "user_openai", + graphId: "wiki-compose-research", + userId: "u1", + tier: "free", + db, + }), + ).rejects.toBeInstanceOf(HTTPException); + expect(mockGetUserAiCredentialPlaintext).not.toHaveBeenCalled(); + }); + + it("allows user_google BYOK for wiki-compose-research", async () => { await assertComposeBackendReady({ - backend: "user_openai", + backend: "user_google", graphId: "wiki-compose-research", userId: "u1", tier: "free", db, }); + expect(mockGetUserAiCredentialPlaintext).toHaveBeenCalledWith("u1", "google", db); + }); + + it("allows non-Google BYOK for ingest planner (provider-specific models)", async () => { + await assertComposeBackendReady({ + backend: "user_openai", + graphId: "ingest-planner", + userId: "u1", + tier: "free", + db, + }); expect(mockGetUserAiCredentialPlaintext).toHaveBeenCalledWith("u1", "openai", db); }); diff --git a/server/api/src/__tests__/agents/core/llm/wikiComposeModelId.test.ts b/server/api/src/__tests__/agents/core/llm/wikiComposeModelId.test.ts new file mode 100644 index 00000000..71a5fbf8 --- /dev/null +++ b/server/api/src/__tests__/agents/core/llm/wikiComposeModelId.test.ts @@ -0,0 +1,46 @@ +/** + * Tests for fixed Wiki Compose model id resolution. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + resolveWikiComposeModelId, + WIKI_COMPOSE_MODEL_ID, +} from "../../../../agents/core/llm/wikiComposeModelId.js"; + +const mockDb = { + select: vi.fn(), +}; + +function chainLimit(rows: unknown[]) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue(rows), + }; + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveWikiComposeModelId", () => { + it("returns the fixed id when the row is active and tier-accessible", async () => { + mockDb.select.mockReturnValueOnce(chainLimit([{ id: WIKI_COMPOSE_MODEL_ID }])); + const id = await resolveWikiComposeModelId("orchestrator", "free", mockDb as never); + expect(id).toBe(WIKI_COMPOSE_MODEL_ID); + }); + + it("returns the fixed id even when no DB row matches", async () => { + mockDb.select.mockReturnValueOnce(chainLimit([])); + const id = await resolveWikiComposeModelId("draft", "pro", mockDb as never); + expect(id).toBe("google:gemini-3.5-flash"); + }); + + it("uses the same id for orchestrator and draft roles", async () => { + mockDb.select.mockReturnValue(chainLimit([{ id: WIKI_COMPOSE_MODEL_ID }])); + const orchestrator = await resolveWikiComposeModelId("orchestrator", "free", mockDb as never); + const draft = await resolveWikiComposeModelId("draft", "free", mockDb as never); + expect(orchestrator).toBe(draft); + }); +}); diff --git a/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts b/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts index d923995e..afaa639a 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/nodes/planQueries.test.ts @@ -8,6 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { createZediChatModel } = vi.hoisted(() => ({ createZediChatModel: vi.fn() })); +vi.mock("../../../../../agents/core/llm/wikiComposeModelId.js", () => ({ + WIKI_COMPOSE_MODEL_ID: "google:gemini-3.5-flash", + resolveWikiComposeModelId: vi.fn(async () => "google:gemini-3.5-flash"), +})); + vi.mock("../../../../../agents/core/llm/modelFactory.js", async () => { const actual = await vi.importActual< typeof import("../../../../../agents/core/llm/modelFactory.js") diff --git a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts index 1c8f7c8b..edd3a053 100644 --- a/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts +++ b/server/api/src/__tests__/agents/subgraphs/research/researchGraph.modelGuard.test.ts @@ -15,6 +15,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { createZediChatModel } = vi.hoisted(() => ({ createZediChatModel: vi.fn() })); +vi.mock("../../../../agents/core/llm/wikiComposeModelId.js", () => ({ + WIKI_COMPOSE_MODEL_ID: "google:gemini-3.5-flash", + resolveWikiComposeModelId: vi.fn(async () => "google:gemini-3.5-flash"), +})); + vi.mock("../../../../agents/core/llm/modelFactory.js", async () => { const actual = await vi.importActual< typeof import("../../../../agents/core/llm/modelFactory.js") diff --git a/server/api/src/agents/core/composeBackendValidation.ts b/server/api/src/agents/core/composeBackendValidation.ts index b2856532..f8ffd66b 100644 --- a/server/api/src/agents/core/composeBackendValidation.ts +++ b/server/api/src/agents/core/composeBackendValidation.ts @@ -2,17 +2,18 @@ * Pre-flight BYOK checks for Wiki Compose session creation (#951). * Wiki Compose セッション作成前の BYOK 事前チェック(#951)。 * - * Static env model ids are not validated here — provider matching is enforced at - * runtime via {@link resolveComposeModelId}. This function only verifies that - * the user has a stored credential when the graph will call an LLM. + * Wiki Compose uses a fixed Google model at runtime ({@link WIKI_COMPOSE_MODEL_ID}). + * BYOK sessions still need a stored credential when the graph calls an LLM; provider + * alignment with the fixed model is the caller's responsibility for now. * - * 静的 env モデル id との provider 照合は行わない。実行時の - * `resolveComposeModelId` が provider 整合を担保する。本関数は LLM を呼ぶ - * グラフで credential が存在するかだけを確認する。 + * Wiki Compose は実行時に Google 固定モデルを使う。BYOK 時は LLM 呼び出し前に + * credential の有無だけをここで確認する。 */ import { HTTPException } from "hono/http-exception"; import type { Database, UserTier } from "../../types/index.js"; import { getComposeModelIdsForGraph } from "./composeModelConfig.js"; +import { WIKI_COMPOSE_GRAPH_ID } from "../graphs/wikiCompose/index.js"; +import { RESEARCH_GRAPH_ID } from "../subgraphs/research/index.js"; import { backendToCredentialProvider, isUserByokBackend, @@ -20,6 +21,9 @@ import { } from "./types/executionBackend.js"; import { getUserAiCredentialPlaintext } from "../../services/userAiCredentialService.js"; +/** Graphs that always call the pinned Google Wiki Compose model. */ +const WIKI_COMPOSE_GOOGLE_MODEL_GRAPHS = new Set([WIKI_COMPOSE_GRAPH_ID, RESEARCH_GRAPH_ID]); + /** * Ensure a BYOK backend has stored credentials when the target graph uses LLMs. * LLM を使うグラフ向け BYOK backend に credential が存在するか検証する。 @@ -33,6 +37,13 @@ export async function assertComposeBackendReady(input: { }): Promise { if (!isUserByokBackend(input.backend)) return; + if (WIKI_COMPOSE_GOOGLE_MODEL_GRAPHS.has(input.graphId) && input.backend !== "user_google") { + throw new HTTPException(400, { + message: + 'Wiki Compose uses a fixed Google model; use backend "user_google" or "zedi_managed".', + }); + } + const modelIds = getComposeModelIdsForGraph(input.graphId); // Model-less graphs (e.g. wiki-maintenance) never call `createZediChatModel`. // LLM を呼ばないグラフ(wiki-maintenance 等)は credential 不要。 diff --git a/server/api/src/agents/core/composeModelConfig.ts b/server/api/src/agents/core/composeModelConfig.ts index e635bb1e..c996cfe3 100644 --- a/server/api/src/agents/core/composeModelConfig.ts +++ b/server/api/src/agents/core/composeModelConfig.ts @@ -7,13 +7,7 @@ import { WIKI_MAINTENANCE_GRAPH_ID } from "../graphs/wikiMaintenance/index.js"; import { getOrchestratorModelId } from "../subgraphs/research/nodes/planQueries.js"; import { RESEARCH_GRAPH_ID } from "../subgraphs/research/index.js"; import { INGEST_PLANNER_GRAPH_ID } from "../graphs/ingest/index.js"; - -const DRAFT_MODEL_ENV = "WIKI_COMPOSE_DRAFT_MODEL_ID"; -const DRAFT_MODEL_FALLBACK = "claude-3-5-sonnet"; - -function getDraftModelId(): string { - return process.env[DRAFT_MODEL_ENV]?.trim() || DRAFT_MODEL_FALLBACK; -} +import { WIKI_COMPOSE_MODEL_ID } from "./llm/wikiComposeModelId.js"; /** * Model row ids (`ai_models.id`) that a compose graph run will call via `createZediChatModel`. @@ -23,11 +17,12 @@ export function getComposeModelIdsForGraph(graphId: string): string[] { // Lint-only graph — no `createZediChatModel` calls; BYOK must not require orchestrator keys. if (graphId === WIKI_MAINTENANCE_GRAPH_ID) return []; if (graphId === WIKI_COMPOSE_GRAPH_ID) { - const orchestrator = getOrchestratorModelId(); - const draft = getDraftModelId(); - return orchestrator === draft ? [orchestrator] : [orchestrator, draft]; + return [WIKI_COMPOSE_MODEL_ID]; + } + if (graphId === RESEARCH_GRAPH_ID) { + return [WIKI_COMPOSE_MODEL_ID]; } - if (graphId === RESEARCH_GRAPH_ID || graphId === INGEST_PLANNER_GRAPH_ID) { + if (graphId === INGEST_PLANNER_GRAPH_ID) { return [getOrchestratorModelId()]; } return [getOrchestratorModelId()]; diff --git a/server/api/src/agents/core/llm/resolveComposeModelId.ts b/server/api/src/agents/core/llm/resolveComposeModelId.ts index 77ad8d2e..55ac9ba2 100644 --- a/server/api/src/agents/core/llm/resolveComposeModelId.ts +++ b/server/api/src/agents/core/llm/resolveComposeModelId.ts @@ -1,8 +1,8 @@ /** - * Resolve `ai_models.id` for Wiki Compose nodes so BYOK backends use a matching provider. + * Resolve `ai_models.id` for non–Wiki Compose graphs (e.g. ingest planner). + * Wiki Compose uses {@link resolveWikiComposeModelId} instead. * - * Wiki Compose の LLM ノード用 model id 解決。BYOK backend では provider が一致する - * active モデルを選び、`BackendProviderMismatchError` でセッション全体が落ちるのを防ぐ。 + * Wiki Compose 以外(ingest planner 等)向けの model id 解決。 */ import { and, asc, eq } from "drizzle-orm"; import { aiModels } from "../../../schema/index.js"; diff --git a/server/api/src/agents/core/llm/wikiComposeModelId.ts b/server/api/src/agents/core/llm/wikiComposeModelId.ts new file mode 100644 index 00000000..3019bb0d --- /dev/null +++ b/server/api/src/agents/core/llm/wikiComposeModelId.ts @@ -0,0 +1,54 @@ +/** + * Fixed Wiki Compose model id (temporary until per-role / per-backend selection returns). + * Wiki Compose 用の固定モデル id(将来ロール別選択に戻すまでの暫定)。 + */ +import { and, eq } from "drizzle-orm"; +import { aiModels } from "../../../schema/index.js"; +import type { Database, UserTier } from "../../../types/index.js"; +import type { ComposeModelRole } from "./resolveComposeModelId.js"; + +/** + * `ai_models.id` used by every Wiki Compose LLM node and the `web_search` tool. + * すべての Wiki Compose LLM ノードと `web_search` ツールが使う `ai_models.id`。 + */ +export const WIKI_COMPOSE_MODEL_ID = "google:gemini-3.5-flash" as const; + +function tierFilter(tier: UserTier) { + if (tier === "pro") return undefined; + return eq(aiModels.tierRequired, "free"); +} + +/** + * When the fixed row exists and is accessible, return it; otherwise still return + * {@link WIKI_COMPOSE_MODEL_ID} so callers fail consistently in `validateModelAccess`. + * 行が active かつ tier 的に使えるときだけ DB 上の id を返し、それ以外は固定 id を返す。 + */ +async function fixedModelIdIfAccessible(db: Database, tier: UserTier): Promise { + const tierClause = tierFilter(tier); + const [row] = await db + .select({ id: aiModels.id }) + .from(aiModels) + .where( + and( + eq(aiModels.id, WIKI_COMPOSE_MODEL_ID), + eq(aiModels.isActive, true), + ...(tierClause ? [tierClause] : []), + ), + ) + .limit(1); + return row?.id ?? WIKI_COMPOSE_MODEL_ID; +} + +/** + * Resolve the model row id for Wiki Compose orchestrator / draft / research nodes. + * `role` is accepted for API stability; all roles map to {@link WIKI_COMPOSE_MODEL_ID} for now. + * + * Wiki Compose の model id 解決。現時点では role に関わらず gemini-3.5-flash 固定。 + */ +export async function resolveWikiComposeModelId( + _role: ComposeModelRole, + _tier: UserTier, + db: Database, +): Promise { + return fixedModelIdIfAccessible(db, _tier); +} diff --git a/server/api/src/agents/core/tools/resolveWebSearchModel.ts b/server/api/src/agents/core/tools/resolveWebSearchModel.ts index f4ad2fe9..0e402204 100644 --- a/server/api/src/agents/core/tools/resolveWebSearchModel.ts +++ b/server/api/src/agents/core/tools/resolveWebSearchModel.ts @@ -5,13 +5,14 @@ * `useGoogleSearch` for Google) を呼ぶため、Anthropic-only な選択では成立しない。 * 本ヘルパは次の優先順で model を選ぶ: * - * 1. `process.env.WIKI_COMPOSE_WEB_SEARCH_MODEL_ID` (explicit override; `ai_models.id`) + * 1. {@link WIKI_COMPOSE_MODEL_ID} when active and tier-accessible + * 2. `process.env.WIKI_COMPOSE_WEB_SEARCH_MODEL_ID` (explicit override; `ai_models.id`) * — 必ず active かつ tier 通過することを DB 側で確認する(coderabbit review #956: * 不正な override で `createZediChatModel` が失敗してエラー envelope になる * のを防ぐ)。 - * 2. `ai_models` の active な OpenAI モデルで最安 (`input_cost_units` ASC, `output_cost_units` ASC) - * 3. `ai_models` の active な Google モデルで最安 - * 4. 何も無ければ `null` を返す(ツール側は empty result + note を返す)。 + * 3. `ai_models` の active な OpenAI モデルで最安 (`input_cost_units` ASC, `output_cost_units` ASC) + * 4. `ai_models` の active な Google モデルで最安 + * 5. 何も無ければ `null` を返す(ツール側は empty result + note を返す)。 * * Returns the `ai_models.id` so `createZediChatModel({ modelId })` can validate * tier access and resolve the API key uniformly. Centralising the choice in one @@ -25,6 +26,7 @@ import { and, asc, eq, inArray } from "drizzle-orm"; import { aiModels } from "../../../schema/index.js"; import type { Database, UserTier } from "../../../types/index.js"; +import { WIKI_COMPOSE_MODEL_ID } from "../llm/wikiComposeModelId.js"; const ENV_OVERRIDE = "WIKI_COMPOSE_WEB_SEARCH_MODEL_ID"; @@ -48,6 +50,20 @@ export async function resolveWebSearchModelId( db: Database, tier: UserTier, ): Promise { + const tierClause = tierFilter(tier); + const [fixedRow] = await db + .select({ id: aiModels.id }) + .from(aiModels) + .where( + and( + eq(aiModels.id, WIKI_COMPOSE_MODEL_ID), + eq(aiModels.isActive, true), + ...(tierClause ? [tierClause] : []), + ), + ) + .limit(1); + if (fixedRow) return fixedRow.id; + const override = process.env[ENV_OVERRIDE]?.trim(); if (override) { // Validate the override before returning: it must be active and @@ -73,7 +89,6 @@ export async function resolveWebSearchModelId( // override が使えない場合は通常検索にフォールバックする。 } - const tierClause = tierFilter(tier); const rows = await db .select({ id: aiModels.id, diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts index 263a25f6..dbd6fe14 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/briefDialogue.ts @@ -16,7 +16,7 @@ import { randomUUID } from "node:crypto"; import { z } from "zod"; import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; -import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; +import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; import { loadPageSnapshot } from "./shared/loadPageSnapshot.js"; import { dispatchComposePhase } from "./shared/dispatch.js"; @@ -117,7 +117,7 @@ export async function briefDialogue( // セッション開始時に 1 度だけ読み、以後は state を参照する。 const snapshot = state.pageSnapshot ?? (await loadPageSnapshot(ctx.db, ctx.pageId)); - const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db); + const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db); const model = await createZediChatModel({ modelId, userId: ctx.userId, diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts index 5895cf80..212b0f54 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/draftSections.ts @@ -15,7 +15,7 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; -import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; +import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; import { dispatchComposePhase, dispatchComposeSection } from "./shared/dispatch.js"; import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; @@ -127,7 +127,7 @@ export async function draftSections( return { draftedSections: [], phase: "draft:completed" }; } - const modelId = await resolveComposeModelId("draft", ctx.backend, ctx.tier, ctx.db); + const modelId = await resolveWikiComposeModelId("draft", ctx.tier, ctx.db); const model = await createZediChatModel({ modelId, userId: ctx.userId, diff --git a/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts b/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts index e25af60d..24bc3225 100644 --- a/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts +++ b/server/api/src/agents/graphs/wikiCompose/nodes/structureDialogue.ts @@ -19,7 +19,7 @@ import { structureDialogueFallbackOutline, } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; -import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; +import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "../../../subgraphs/research/nodes/shared/getGraphContext.js"; import { dispatchComposePhase } from "./shared/dispatch.js"; import type { WikiComposeStateType, WikiComposeStateUpdate } from "../state.js"; @@ -83,7 +83,7 @@ export async function structureDialogue( await dispatchComposePhase({ phase: "structure", status: "entered" }, config); - const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db); + const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db); const model = await createZediChatModel({ modelId, userId: ctx.userId, diff --git a/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts b/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts index 536be2bb..aefb2cae 100644 --- a/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts +++ b/server/api/src/agents/subgraphs/research/nodes/evaluateSufficiency.ts @@ -12,7 +12,7 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { z } from "zod"; import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; -import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; +import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; import { dispatchResearchEvaluation } from "./shared/dispatchSseCustom.js"; import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js"; @@ -72,7 +72,7 @@ export async function evaluateSufficiency( ): Promise { const ctx = getGraphContext(config); - const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db); + const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db); const model = await createZediChatModel({ modelId, userId: ctx.userId, diff --git a/server/api/src/agents/subgraphs/research/nodes/planQueries.ts b/server/api/src/agents/subgraphs/research/nodes/planQueries.ts index 33cba0d2..f17539e7 100644 --- a/server/api/src/agents/subgraphs/research/nodes/planQueries.ts +++ b/server/api/src/agents/subgraphs/research/nodes/planQueries.ts @@ -16,14 +16,15 @@ import { randomUUID } from "node:crypto"; import { z } from "zod"; import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; -import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; +import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; import { dispatchResearchIteration } from "./shared/dispatchSseCustom.js"; import type { ResearchLoopStateType, ResearchLoopStateUpdate } from "../state.js"; import type { PlannedQuery, Source } from "../types.js"; /** - * @deprecated Use {@link resolveComposeModelId} with graph context. Kept for tests importing the symbol. + * @deprecated Use {@link resolveWikiComposeModelId} for Wiki Compose graphs. + * Kept for ingest planner BYOK preflight ({@link getComposeModelIdsForGraph}). */ export function getOrchestratorModelId(): string { return process.env.WIKI_COMPOSE_ORCHESTRATOR_MODEL_ID?.trim() || "claude-3-5-haiku"; @@ -100,7 +101,7 @@ export async function planQueries( // maxIterations は既存 state を優先しつつ 1..5 にクランプ。 const maxIterations = clampMaxIterations(state.maxIterations ?? 3); - const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db); + const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db); const model = await createZediChatModel({ modelId, userId: ctx.userId, diff --git a/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts b/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts index 41fe7129..95738e13 100644 --- a/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts +++ b/server/api/src/agents/subgraphs/research/nodes/refineQueries.ts @@ -10,7 +10,7 @@ import type { LangGraphRunnableConfig } from "@langchain/langgraph"; import { randomUUID } from "node:crypto"; import { composeContentLocaleInstruction } from "../../../core/composeLocale.js"; import { createZediChatModel } from "../../../core/llm/modelFactory.js"; -import { resolveComposeModelId } from "../../../core/llm/resolveComposeModelId.js"; +import { resolveWikiComposeModelId } from "../../../core/llm/wikiComposeModelId.js"; import { getGraphContext } from "./shared/getGraphContext.js"; import { dispatchResearchIteration } from "./shared/dispatchSseCustom.js"; import { planQueriesSchema } from "./planQueries.js"; @@ -62,7 +62,7 @@ export async function refineQueries( ): Promise { const ctx = getGraphContext(config); - const modelId = await resolveComposeModelId("orchestrator", ctx.backend, ctx.tier, ctx.db); + const modelId = await resolveWikiComposeModelId("orchestrator", ctx.tier, ctx.db); const model = await createZediChatModel({ modelId, userId: ctx.userId, diff --git a/src/hooks/useInitialComposeBackend.ts b/src/hooks/useInitialComposeBackend.ts index f8b73895..485b5b88 100644 --- a/src/hooks/useInitialComposeBackend.ts +++ b/src/hooks/useInitialComposeBackend.ts @@ -5,7 +5,7 @@ import { useEffect, useState } from "react"; import { loadAISettings, AI_SETTINGS_CHANGED_EVENT } from "@/lib/aiSettings"; import { fetchUserAiCredentialsStatus } from "@/lib/userAiCredentials"; -import { resolveComposeBackendFromAiSettings } from "@/lib/wikiCompose/resolveComposeBackend"; +import { resolveWikiComposeBackendFromAiSettings } from "@/lib/wikiCompose/resolveComposeBackend"; import type { ComposeExecutionBackend } from "@/lib/wikiCompose/backends"; import { DEFAULT_AI_SETTINGS } from "@/types/ai"; @@ -25,7 +25,7 @@ const EMPTY_CREDENTIALS = { async function loadComposeBackendFromSettings(): Promise { const settings = (await loadAISettings()) ?? DEFAULT_AI_SETTINGS; const credentials = await fetchUserAiCredentialsStatus().catch(() => EMPTY_CREDENTIALS); - return resolveComposeBackendFromAiSettings(settings, credentials); + return resolveWikiComposeBackendFromAiSettings(settings, credentials); } export interface UseInitialComposeBackendOptions { diff --git a/src/lib/wikiCompose/resolveComposeBackend.test.ts b/src/lib/wikiCompose/resolveComposeBackend.test.ts index 51d24499..815e4594 100644 --- a/src/lib/wikiCompose/resolveComposeBackend.test.ts +++ b/src/lib/wikiCompose/resolveComposeBackend.test.ts @@ -4,6 +4,7 @@ import { isComposeBackendAvailable, resolveComposeBackendFromAiSettings, resolvePreferredComposeBackend, + resolveWikiComposeBackendFromAiSettings, } from "./resolveComposeBackend"; import type { UserAiCredentialsStatus } from "@/lib/userAiCredentials"; @@ -95,3 +96,31 @@ describe("resolveComposeBackendFromAiSettings", () => { ).toBe("user_openai"); }); }); + +describe("resolveWikiComposeBackendFromAiSettings", () => { + it("falls back to zedi_managed when preferred BYOK is not Google", () => { + expect( + resolveWikiComposeBackendFromAiSettings( + baseSettings({ apiMode: "user_api_key", provider: "openai", isConfigured: true }), + credentialsOpenAi, + ), + ).toBe("zedi_managed"); + }); + + it("uses user_google when settings and credentials are Google BYOK", () => { + const credentialsGoogle: UserAiCredentialsStatus = { + storageEnabled: true, + providers: [ + { provider: "anthropic", configured: false }, + { provider: "openai", configured: false }, + { provider: "google", configured: true }, + ], + }; + expect( + resolveWikiComposeBackendFromAiSettings( + baseSettings({ apiMode: "user_api_key", provider: "google", isConfigured: true }), + credentialsGoogle, + ), + ).toBe("user_google"); + }); +}); diff --git a/src/lib/wikiCompose/resolveComposeBackend.ts b/src/lib/wikiCompose/resolveComposeBackend.ts index c7b8bfc1..ef268db2 100644 --- a/src/lib/wikiCompose/resolveComposeBackend.ts +++ b/src/lib/wikiCompose/resolveComposeBackend.ts @@ -58,3 +58,20 @@ export function resolveComposeBackendFromAiSettings( } return "zedi_managed"; } + +/** + * Wiki Compose backend: only `user_google` BYOK or `zedi_managed` match the pinned + * Google compose model (`google:gemini-3.5-flash`). Other BYOK providers fall back. + * + * Wiki Compose は Google 固定モデルのため、Google BYOK か zedi_managed のみ使う。 + */ +export function resolveWikiComposeBackendFromAiSettings( + settings: AISettings, + credentials: UserAiCredentialsStatus, +): ComposeExecutionBackend { + const preferred = resolvePreferredComposeBackend(settings); + if (preferred === "user_google" && isComposeBackendAvailable("user_google", credentials)) { + return "user_google"; + } + return "zedi_managed"; +}