Skip to content
Merged
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
45 changes: 45 additions & 0 deletions .changeset/ai-model-status-summary-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@object-ui/i18n": patch
"@object-ui/app-shell": patch
---

fix(i18n): compose the AI-model diagnostics summary client-side instead of rendering the server's English string (objectui#2886)

`CloudAiModelStatus` rendered `report.summary` verbatim — the most prominent
line on the panel, in English for every locale.

Reading `objectstack-ai/cloud` settled how to fix it. The server **cannot**
localize that string as currently built:

- `service-ai/src/effective-model.ts:117` assembles it as a hard-coded English
template literal, with no locale parameter;
- `service-ai/src/routes/ai-routes.ts:395` declares `handler: async () => …` —
it takes **no request argument**, so it cannot read `Accept-Language` even
though `createAuthenticatedFetch` has been sending it since objectui#1319.

But no server change is needed, because every ingredient of the sentence is
already in the structured payload: `conversational.model`,
`conversational.source`, `structured.model`, `structured.pinned`, and
`routing.{free,paid}`. The issue proposed "return structured data instead of a
sentence" as the better fix — the server was already doing that; the client
just wasn't using it.

The panel now composes the line from those fields. `sourceLabel()` already
produced exactly the two clauses the server hand-rolls — "pinned by X" /
"code default (no env override)", and "same as build/ask" for an unpinned
structured model — so no new source vocabulary was required.

**A dropped diagnostic, not just untranslated text.** The client's
`EffectiveModelReport` never declared `routing`, which the server has always
sent conditionally. Its only appearance anywhere was inside the English summary,
so non-English admins could not see the plan→model routing policy **at all**.
It is now declared and surfaced.

Also fixed: `attributeSource` emits the bare token `'unknown'` when the adapter
cannot report a model, and `sourceLabel` fell through to rendering it raw.

Four keys added to all ten packs (`summary`, `summaryRouting`, `modelUnknown`,
`sourceUnknown`), so the full-parity guard from objectui#2909 stays green.

The panel had **no test coverage at all**; it now has five, mutation-tested by
restoring `<p>{report.summary}</p>` — which fails four of them.
119 changes: 119 additions & 0 deletions packages/app-shell/src/console/diagnostics/CloudAiModelStatus.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The AI-model diagnostics panel composes its own summary line (objectui#2886).
*
* `service-ai` builds `report.summary` as a hard-coded English template literal
* and its route handler takes no request argument, so it cannot honour
* `Accept-Language` even though the client has been sending it since
* objectui#1319. Every ingredient of that sentence is already in the structured
* fields, so the panel assembles a localized equivalent and ignores `summary`.
*
* These tests pin the two things that regressed before: the English string must
* not reach the DOM, and `routing` — which the server always sent but the client
* never declared — must be surfaced.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { I18nProvider, createI18n } from '@object-ui/i18n';

vi.mock('@object-ui/auth', () => ({
createAuthenticatedFetch: () => vi.fn().mockImplementation(() => Promise.resolve(mockResponse())),
}));

let payload: Record<string, unknown>;
const mockResponse = () =>
({ ok: true, status: 200, json: () => Promise.resolve(payload) }) as unknown as Response;

import { CloudAiModelStatus } from './CloudAiModelStatus';

/** The English sentence the server sends and this panel must NOT render. */
const SERVER_SUMMARY =
'build/ask uses gpt-5 (pinned by OS_AI_MODEL); structured uses gpt-5-mini. ' +
'Routing policy: free plans → gpt-5-mini, paid plans → gpt-5.';

function baseReport(extra: Record<string, unknown> = {}) {
return {
conversational: { model: 'gpt-5', source: 'env:OS_AI_MODEL' },
structured: { model: 'gpt-5-mini', pinned: true, source: 'env:OS_AI_MODEL_STRUCTURED' },
reasoningEffort: { effective: 'medium', source: 'code-default' },
adapter: 'vercel',
overrides: { OS_AI_MODEL: 'gpt-5' },
summary: SERVER_SUMMARY,
...extra,
};
}

function renderPanel(lang = 'en') {
// `I18nProvider` takes a config/instance, not a `language` prop — passing one
// is silently ignored at runtime, so pin the language on the instance.
const instance = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
return render(
<I18nProvider instance={instance}>
<CloudAiModelStatus />
</I18nProvider>,
);
}

beforeEach(() => {
payload = baseReport();
});
afterEach(cleanup);

describe('CloudAiModelStatus — summary is composed client-side', () => {
it('renders a localized summary and never the server’s English string', async () => {
renderPanel('en');
const line = await screen.findByTestId('ai-model-summary');

expect(line).toHaveTextContent('gpt-5');
expect(line).toHaveTextContent('gpt-5-mini');
// The regression: `<p>{report.summary}</p>` put this verbatim on screen for
// every locale.
expect(line.textContent).not.toBe(SERVER_SUMMARY);
expect(document.body.textContent).not.toContain('build/ask uses');
});

it('surfaces the routing policy the client type used to drop', async () => {
payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } });
renderPanel('en');
const line = await screen.findByTestId('ai-model-summary');
// Routing only ever appeared inside the English summary, so non-English
// admins could not see it at all.
expect(line).toHaveTextContent(/Routing policy/i);
expect(line).toHaveTextContent('gpt-5-mini');
});

it('omits the routing clause when the host does not inject one', async () => {
renderPanel('en');
const line = await screen.findByTestId('ai-model-summary');
expect(line).not.toHaveTextContent(/Routing policy/i);
});

it('translates the summary — a zh render shares no sentence text with en', async () => {
payload = baseReport({ routing: { free: 'gpt-5-mini', paid: 'gpt-5' } });
const { unmount } = renderPanel('en');
const en = (await screen.findByTestId('ai-model-summary')).textContent ?? '';
unmount();

renderPanel('zh');
const zh = (await screen.findByTestId('ai-model-summary')).textContent ?? '';

expect(zh).not.toBe(en);
expect(zh).toMatch(/[一-鿿]/);
// Model ids are identifiers, not prose — they must survive translation.
expect(zh).toContain('gpt-5');
});

it('falls back to a translated placeholder when the adapter reports no model', async () => {
payload = baseReport({
conversational: { model: undefined, source: 'unknown' },
structured: { model: undefined, pinned: false, source: 'inherits-conversational' },
});
renderPanel('en');
const line = await screen.findByTestId('ai-model-summary');
// `attributeSource` emits the bare token 'unknown'; it used to render raw.
expect(line.textContent).not.toMatch(/\bunknown\s*\)/);
expect(line).toHaveTextContent(/not reported by the adapter/i);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ interface EffectiveModelReport {
adapter: string;
provider?: string;
overrides: Record<string, string | null>;
/**
* Plan→model routing for this deploy, injected by the host (objectos-runtime
* owns `planToAiModel`; service-ai stays plan-agnostic), so the report says
* what EACH tier gets rather than only the model the live adapter happens to
* hold. Omitted by hosts with no plan concept.
*
* The server has always sent this; the client just never declared it, so the
* only place it surfaced was inside the English `summary` string — meaning
* non-English admins could not see the routing policy at all (objectui#2886).
*/
routing?: { free: string; paid: string };
/**
* Server-composed one-line summary. **Deliberately not rendered.**
*
* It is assembled in `service-ai`'s `buildEffectiveModelReport` as a
* hard-coded English template literal, and the route handler there takes no
* request argument at all — so it cannot read `Accept-Language` even though
* `createAuthenticatedFetch` has been sending it since objectui#1319. Every
* ingredient of the sentence is already in the structured fields above, so
* this panel composes its own localized version instead. Kept in the type
* because it is part of the wire contract, not because it is used.
*/
summary: string;
}

Expand All @@ -53,6 +75,10 @@ function sourceLabel(source: string, t: TFn): string {
if (source === 'code-default') return t('aiModelStatus.sourceCodeDefault');
if (source === 'inherits-conversational') return t('aiModelStatus.sourceInherits');
if (source.startsWith('env:')) return t('aiModelStatus.sourcePinned', { source: source.slice(4) });
// service-ai's `attributeSource` returns the bare token 'unknown' when the
// adapter cannot report a model — reachable, and it used to render as raw
// English here (objectui#2886).
if (source === 'unknown') return t('aiModelStatus.sourceUnknown');
return source;
}

Expand Down Expand Up @@ -136,9 +162,33 @@ export function CloudAiModelStatus({ properties }: CloudAiModelStatusProps) {
const { report } = state;
const setOverrides = Object.entries(report.overrides).filter(([, v]) => v != null);

// The summary the server sends is English-only and unlocalizable at source
// (see `EffectiveModelReport.summary`), so compose the same sentence from the
// structured fields. `sourceLabel` already yields exactly the two clauses the
// server hand-rolls: "pinned by X" / "code default (no env override)" for the
// conversational half, and "same as build/ask" for an unpinned structured
// model — so no new source vocabulary is needed here.
const unknownModel = t('aiModelStatus.modelUnknown');

return (
<div className="space-y-4" data-ai-model-status="ready">
<p className="text-sm text-foreground">{report.summary}</p>
<p className="text-sm text-foreground" data-testid="ai-model-summary">
{t('aiModelStatus.summary', {
conversational: report.conversational.model ?? unknownModel,
conversationalSource: sourceLabel(report.conversational.source, t),
structured: report.structured.model ?? unknownModel,
structuredSource: sourceLabel(report.structured.source, t),
})}
{report.routing ? (
<>
{' '}
{t('aiModelStatus.summaryRouting', {
free: report.routing.free,
paid: report.routing.paid,
})}
</>
) : null}
</p>

<div className="rounded-md border border-border px-3">
<ModelRow
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const ar = {
manageEnvironments: "إدارة البيئات",
},
aiModelStatus: {
summary: "يستخدم الإنشاء / السؤال {{conversational}} ({{conversationalSource}})؛ ويستخدم الإخراج المهيكل {{structured}} ({{structuredSource}}).",
summaryRouting: "سياسة التوجيه: الخطط المجانية ← {{free}}، الخطط المدفوعة ← {{paid}}.",
modelUnknown: "غير معروف",
sourceUnknown: "لم يبلّغ عنه المحوّل",
rowConversational: "نموذج Build / Ask",
rowStructured: "مُهيكل (blueprint / seed)",
rowReasoning: "مستوى الاستدلال",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const de = {
manageEnvironments: "Umgebungen verwalten",
},
aiModelStatus: {
summary: "Erstellen / Fragen nutzt {{conversational}} ({{conversationalSource}}); strukturiert nutzt {{structured}} ({{structuredSource}}).",
summaryRouting: "Routing-Richtlinie: kostenlose Tarife → {{free}}, kostenpflichtige Tarife → {{paid}}.",
modelUnknown: "unbekannt",
sourceUnknown: "vom Adapter nicht gemeldet",
rowConversational: "Build-/Ask-Modell",
rowStructured: "Strukturiert (Blueprint / Seed)",
rowReasoning: "Reasoning-Aufwand",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2576,6 +2576,10 @@ const en = {
manageEnvironments: 'Manage environments',
},
aiModelStatus: {
summary: 'Build / Ask uses {{conversational}} ({{conversationalSource}}); structured uses {{structured}} ({{structuredSource}}).',
summaryRouting: 'Routing policy: free plans → {{free}}, paid plans → {{paid}}.',
modelUnknown: 'unknown',
sourceUnknown: 'not reported by the adapter',
rowConversational: 'Build / Ask model',
rowStructured: 'Structured (blueprint / seed)',
rowReasoning: 'Reasoning effort',
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const es = {
manageEnvironments: "Gestionar entornos",
},
aiModelStatus: {
summary: "Crear / Preguntar usa {{conversational}} ({{conversationalSource}}); estructurado usa {{structured}} ({{structuredSource}}).",
summaryRouting: "Política de enrutamiento: planes gratuitos → {{free}}, planes de pago → {{paid}}.",
modelUnknown: "desconocido",
sourceUnknown: "no informado por el adaptador",
rowConversational: "Modelo Build / Ask",
rowStructured: "Estructurado (blueprint / seed)",
rowReasoning: "Esfuerzo de razonamiento",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const fr = {
manageEnvironments: "Gérer les environnements",
},
aiModelStatus: {
summary: "Créer / Demander utilise {{conversational}} ({{conversationalSource}}) ; structuré utilise {{structured}} ({{structuredSource}}).",
summaryRouting: "Politique de routage : offres gratuites → {{free}}, offres payantes → {{paid}}.",
modelUnknown: "inconnu",
sourceUnknown: "non communiqué par l'adaptateur",
rowConversational: "Modèle Build / Ask",
rowStructured: "Structuré (blueprint / seed)",
rowReasoning: "Effort de raisonnement",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const ja = {
manageEnvironments: "環境を管理",
},
aiModelStatus: {
summary: "ビルド / 質問は {{conversational}}({{conversationalSource}})、構造化出力は {{structured}}({{structuredSource}})を使用します。",
summaryRouting: "ルーティングポリシー: 無料プラン → {{free}}、有料プラン → {{paid}}。",
modelUnknown: "不明",
sourceUnknown: "アダプターから報告なし",
rowConversational: "Build / Ask モデル",
rowStructured: "構造化(ブループリント / シード)",
rowReasoning: "推論の強度",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const ko = {
manageEnvironments: "환경 관리",
},
aiModelStatus: {
summary: "빌드 / 질문은 {{conversational}}({{conversationalSource}}), 구조화 출력은 {{structured}}({{structuredSource}})을(를) 사용합니다.",
summaryRouting: "라우팅 정책: 무료 요금제 → {{free}}, 유료 요금제 → {{paid}}.",
modelUnknown: "알 수 없음",
sourceUnknown: "어댑터가 보고하지 않음",
rowConversational: "Build / Ask 모델",
rowStructured: "구조화(블루프린트 / 시드)",
rowReasoning: "추론 강도",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const pt = {
manageEnvironments: "Gerenciar ambientes",
},
aiModelStatus: {
summary: "Criar / Perguntar usa {{conversational}} ({{conversationalSource}}); estruturado usa {{structured}} ({{structuredSource}}).",
summaryRouting: "Política de roteamento: planos gratuitos → {{free}}, planos pagos → {{paid}}.",
modelUnknown: "desconhecido",
sourceUnknown: "não informado pelo adaptador",
rowConversational: "Modelo Build / Ask",
rowStructured: "Estruturado (blueprint / seed)",
rowReasoning: "Esforço de raciocínio",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2475,6 +2475,10 @@ const ru = {
manageEnvironments: "Управление окружениями",
},
aiModelStatus: {
summary: "Сборка / вопросы используют {{conversational}} ({{conversationalSource}}); структурированный вывод — {{structured}} ({{structuredSource}}).",
summaryRouting: "Политика маршрутизации: бесплатные тарифы → {{free}}, платные тарифы → {{paid}}.",
modelUnknown: "неизвестно",
sourceUnknown: "адаптер не сообщил",
rowConversational: "Модель Build / Ask",
rowStructured: "Структурированный (blueprint / seed)",
rowReasoning: "Интенсивность рассуждений",
Expand Down
4 changes: 4 additions & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2561,6 +2561,10 @@ const zh = {
manageEnvironments: '管理环境',
},
aiModelStatus: {
summary: '构建 / 提问使用 {{conversational}}({{conversationalSource}});结构化使用 {{structured}}({{structuredSource}})。',
summaryRouting: '路由策略:免费方案 → {{free}},付费方案 → {{paid}}。',
modelUnknown: '未知',
sourceUnknown: '适配器未上报',
rowConversational: 'Build / Ask 模型',
rowStructured: '结构化(蓝图 / 种子)',
rowReasoning: '推理强度',
Expand Down
Loading