diff --git a/README.md b/README.md index ce0e6daef2..d3094a2b28 100644 --- a/README.md +++ b/README.md @@ -106,16 +106,15 @@ See [SPONSORS.md](./SPONSORS.md). - - - + +
SPONSORThanks to SPONSOR for sponsoring this project! BLURBOrcaRouterThanks to OrcaRouter for sponsoring this project! OrcaRouter is one OpenAI-compatible AI gateway for production AI: adaptive routing that grades every prompt and sends it to the model that clears your bar, automatic failover, routing rules as code, zero-markup provider pricing with prompt caching, and guardrails, an agent firewall, and request logs on every call across 200+ models. Pick OrcaRouter in the Add provider picker or run ocx provider add orcarouter; orcarouter/auto is the adaptive router.
---> --- diff --git a/SPONSORS.md b/SPONSORS.md index a0a2ee33b1..76a54c377b 100644 --- a/SPONSORS.md +++ b/SPONSORS.md @@ -45,7 +45,7 @@ sponsor receives: supplied by the sponsor and published verbatim. The maintainer may decline or require edits to text that is false, misleading, disparages third parties, or breaches applicable law or GitHub policy. A second-language blurb (for example Chinese) may run alongside the English one. -- A built-in provider preset (`ocx provider select `) shipped in a public npm release, +- A built-in provider preset (`ocx provider add `) shipped in a public npm release, listed near the top of the provider picker in the dashboard and CLI and marked as a sponsor there. (The registry field and picker ordering that back this land with the first sponsor preset; today the picker follows registry order.) diff --git a/assets/sponsors/orcarouter-overview-mobile.png b/assets/sponsors/orcarouter-overview-mobile.png new file mode 100644 index 0000000000..a43db45789 Binary files /dev/null and b/assets/sponsors/orcarouter-overview-mobile.png differ diff --git a/assets/sponsors/orcarouter-overview.png b/assets/sponsors/orcarouter-overview.png new file mode 100644 index 0000000000..a067b5ea60 Binary files /dev/null and b/assets/sponsors/orcarouter-overview.png differ diff --git a/assets/sponsors/orcarouter-picker.png b/assets/sponsors/orcarouter-picker.png new file mode 100644 index 0000000000..2c4e5b1628 Binary files /dev/null and b/assets/sponsors/orcarouter-picker.png differ diff --git a/assets/sponsors/orcarouter-readme.png b/assets/sponsors/orcarouter-readme.png new file mode 100644 index 0000000000..dfad19db9e Binary files /dev/null and b/assets/sponsors/orcarouter-readme.png differ diff --git a/assets/sponsors/orcarouter.png b/assets/sponsors/orcarouter.png new file mode 100644 index 0000000000..2d65f01c08 Binary files /dev/null and b/assets/sponsors/orcarouter.png differ diff --git a/devlog/_plan/260908_sponsor_overview/010_overview.md b/devlog/_plan/260908_sponsor_overview/010_overview.md new file mode 100644 index 0000000000..92493bf2a9 --- /dev/null +++ b/devlog/_plan/260908_sponsor_overview/010_overview.md @@ -0,0 +1,38 @@ +# Sponsor overview presentation + +Satisfy-spec, C2, one shared UI slice delivered to existing independent PRs #3914 and #3915. Trigger: the maintainer requested concise marketing copy, hyperlinks, tidy design and PR screenshot mockups. Stop after both remote branches and PR descriptions are updated with truthful verification. No merge, release, deployment, credential changes, outreach or new dependencies. No user-defined resource budget; use existing local tools and remote macmini-cf for heavy validation. This document and the local goalplan hold evidence. Escalate only a conflicting remote edit, inaccessible publication or unrelated baseline failure requiring broader scope; report incomplete evidence honestly. + +## Design read + +Developer dashboard, existing neutral theme and system font. Keep compact connection facts and quota data; introduce one quiet sponsor strip with product identity, a two-line value proposition, explicit Sponsor disclosure and named outbound actions. No hero, animation, invented discount or performance claim. Variance 3/10, motion 1/10, density D5. Reuse existing ProviderIcon, button/link tokens and locale dictionaries. Desktop strip places copy and actions side by side; narrow containers wrap actions below copy. Screenshot mockups use actual components and synthetic account/usage values, labelled as fixtures in PR prose. Utility dashboard exemption: no generated concept images. + +## Existing owners and necessity + +- `gui/src/pages/Providers.tsx:289`: existing cached `/api/provider-presets` request; consume its result instead of adding a request/store. +- `gui/src/components/provider-catalog/provider-presets.ts:17`: CatalogPreset already owns sponsor/sponsorUrl/dashboardUrl/note. No backend field or persistence change. +- `gui/src/components/provider-workspace/ProviderDetails.tsx:265`: pass the matched preset to Overview. +- `gui/src/components/provider-workspace/ProviderOverview.tsx:186`: note is duplicated in connection facts and NotesSection. Remove the duplicate and move the existing editable NotesSection into the wider main column. Preserve full note and its editing behavior for every provider. +- `gui/src/styles/provider-workspace-shell.css:606`: existing responsive grid/tokens. +- Searched preset matching, sponsor fields, Overview callers and note-save tests; no equivalent overview sponsor presentation exists. Configuration alone cannot add links to the current text-only view. + +## Diff map + +1. MODIFY Providers cached request typing to CatalogPreset[] and pass matching preset; match canonical id + adapter + normalized endpoint (trailing slash tolerated); mismatched endpoints or absent presets produce no sponsor strip. Do not infer endorsement from name alone. Reuse cache; no new fetch. +2. MODIFY ProviderDetails and ProviderOverview optional preset prop. NEW small ProviderSponsor component in provider-workspace: render only known sponsor identities with active sponsor metadata; localize concise OrcaRouter adaptive-routing and PackyCode multi-tool API-relay descriptions. Preserve exact existing affiliate URL, expose dashboard link only when distinct, HTTP(S) only, new-tab noopener/noreferrer. No HTML parsing of notes. +3. MODIFY Overview: render sponsor strip above columns; remove connection note row, put existing NotesSection after auth summary, leaving right column stats/quota only. Full user note remains visible/editable once. +4. MODIFY existing workspace stylesheet for strip layout, subtle border, readable copy/actions and note wrapping. MODIFY all locale dictionaries for every added key. +5. NEW focused GUI render tests for sponsor links/disclosure, missing/non-sponsor/custom-endpoint cases; extend existing note test to assert exactly one note and continued editing. No root test-map change for GUI tests. +6. MODIFY providers guide and GUI SoT for sponsor overview behavior. ADD desktop and narrow actual-render PNGs per sponsor under existing assets/sponsors; update both PR descriptions, preserving prior scope and verification distinctions. + +## Acceptance and validation + +- Active OrcaRouter API and OAuth presets show the strip only for their configured endpoint; PackyCode only on its sponsor branch. Missing catalog, non-sponsor and changed endpoint remain ordinary provider views. Focused tests activate each branch. +- Exact sponsorURL survives, duplicate dashboard URL is suppressed, unsafe URLs do not become anchors. Provider limitations and arbitrary user note remain complete and occur once; note-save failure keeps draft/error. +- Run focused GUI tests (new sponsor tests plus existing notes, catalog sponsor-pinning and locale parity), lint:i18n, lint and GUI build. Existing scripts confirmed in gui/package.json; target files/locale imports prove coverage. Fresh execution recorded in C, not claimed from script existence. +- For review-ready delivery run root typecheck and full tests plus full GUI tests on isolated macmini-cf checkout; build locally for rendered proof. Existing PR gates already fail before this patch: diagnose separately and do not claim green by inheritance. +- Browser smoke at 1440px and 390px, light/dark, English/Korean: inspect screenshot, actual hyperlinks and keyboard focus, note editing and overflow. No live account data or upstream inference. +- Preserve both original histories: build on sponsor remote heads in this bound worktree with separate local branches, carry shared commit to second branch, push fast-forward to each existing remote after refreshing identity. No native stack changes. + +## Audit and evidence + +Independent audit: GO-WITH-FIXES, one blocking coverage gap. Folded: the browser integration smoke must load the real Providers → Details → Overview chain with delayed catalog resolution, assert sponsor content appears, count the shared preset request, then change the fixture endpoint and verify the strip disappears. Component-only screenshots do not close this row. Branch matrix: Orca API/OAuth positive and Packy absent on Orca head; Packy positive and Orca strip absent on Packy head. Baseline focused tests: 13 pass / 0 fail. Existing CI failure is French modal.badge.sponsor untranslated; correct the sponsor-specific locale value while updating copy. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 31bb2e3d42..edfb760cc2 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -6,6 +6,13 @@ description: Every way opencodex authenticates and talks to an LLM provider — A **provider** is one upstream LLM endpoint plus how to reach it: an adapter, a base URL, an auth mode, and an optional model list. Providers live under `providers` in `~/.opencodex/config.json`. +The dashboard provider Overview separates connection details, account usage and editable notes. +Notes appear once, below the connection and authentication sections. Supported sponsor presets +also show a short introduction, a Sponsor label and links to the provider's site or console. +These links preserve the preset's referral parameters. Sponsor information is shown only when +the configured provider name, adapter and endpoint match the preset; it never changes routing, +account selection or defaults. + ## OpenAI account modes | Provider id | Use | Credential/account rule | @@ -333,6 +340,13 @@ preserves those requested tiers; any backend-specific normalization remains Clin available in the Cline IDE/CLI, not through the API; `minimax/minimax-m2.5` is the documented API free-experimentation model. +**OrcaRouter** ([sponsor](https://github.com/lidge-jun/opencodex/blob/main/SPONSORS.md)) is an +OpenAI-compatible gateway at `https://api.orcarouter.ai/v1` with vendor-namespaced model ids +(`openai/gpt-5.5`, `anthropic/claude-opus-4.8`, `deepseek/deepseek-v4-pro`, ...) and an adaptive +router, `orcarouter/auto`, that grades each prompt and picks the model. Create a key in the +[OrcaRouter console](https://www.orcarouter.ai/console); the preset pins the row near the top of the +Add provider picker and marks it as a sponsor, and nothing else about routing or defaults changes. + | Provider | Base URL | | --- | --- | | **OpenAI (API key)** | `https://api.openai.com/v1` | diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index d91433490d..82fe041b95 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -8,6 +8,7 @@ import { useMemo, useState } from "react"; import { useT } from "../../i18n/shared"; import { bucketPresets, + pinSponsors, filterPresets, type CatalogPreset, } from "./provider-presets"; @@ -95,7 +96,7 @@ export default function ProviderCatalog({ }); }, [catalog, usageRank]); - const buckets = useMemo(() => bucketPresets(ranked), [ranked]); + const buckets = useMemo(() => bucketPresets(pinSponsors(ranked)), [ranked]); const tierList = buckets[tier]; const rows = useMemo(() => filterPresets(tierList, query), [tierList, query]); @@ -112,7 +113,10 @@ export default function ProviderCatalog({ const free = (p.freeTier || p.keyOptional) && p.auth === "key" ? {t("modal.badge.free")} : null; - return <>{free}{auth}; + const sponsor = p.sponsor + ? {t("modal.badge.sponsor")} + : null; + return <>{sponsor}{free}{auth}; }; return ( diff --git a/gui/src/components/provider-catalog/provider-presets.ts b/gui/src/components/provider-catalog/provider-presets.ts index 36f5231fa2..ae05e55e7f 100644 --- a/gui/src/components/provider-catalog/provider-presets.ts +++ b/gui/src/components/provider-catalog/provider-presets.ts @@ -6,7 +6,7 @@ * predicates), search filtering, and deterministic sorting. No React, no fetch. */ -import { providerTier, type ProviderTier, type WorkspaceProvider } from "../../provider-workspace/catalog"; +import { providerTier, type ProviderTier, type WorkspaceProvider, type WorkspaceItem } from "../../provider-workspace/catalog"; import type { ProviderPayload } from "../../provider-payload"; /** Row shape returned by GET /api/provider-presets (mirrors DerivedProviderPreset). */ @@ -28,6 +28,9 @@ export interface CatalogPreset { keyOptional?: boolean; /** Free pricing — may still require an API key (e.g. NVIDIA NIM). */ freeTier?: boolean; + /** Sponsor tier (SPONSORS.md). Sponsor rows are pinned to the top of their tab and chipped. */ + sponsor?: "main" | "standard"; + sponsorUrl?: string; /** * Endpoint picker (e.g. Qwen Cloud). Choice without `baseUrl` = Custom (show text field). */ @@ -36,6 +39,21 @@ export interface CatalogPreset { provider?: ProviderPayload; } +/** A configured name alone cannot identify a sponsor after its endpoint is edited. */ +export function matchingWorkspacePreset(item: WorkspaceItem, presets: CatalogPreset[]): CatalogPreset | undefined { + const endpoint = (value: string) => { + try { + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + return `${url.origin}${url.pathname.replace(/\/+$/, "")}`; + } catch { return undefined; } + }; + const base = endpoint(item.baseUrl); + if (!base) return undefined; + return presets.find(preset => preset.id === item.name && preset.adapter === item.adapter + && endpoint(preset.baseUrl) === base); +} + /** * Adapt a preset row to the WorkspaceProvider shape the tier predicates expect * (preset `auth` ↔ config `authMode`; booleans normalized). @@ -68,3 +86,20 @@ export function filterPresets(presets: CatalogPreset[], query: string): CatalogP if (!q) return presets; return presets.filter(p => p.label.toLowerCase().includes(q) || p.id.toLowerCase().includes(q)); } + +const SPONSOR_RANK: Record, number> = { main: 0, standard: 1 }; + +/** + * Sponsor rows first — Main before Standard, alphabetical by label within a tier — then the + * caller's order untouched. Stable, so usage ranking still decides the non-sponsor tail. + * Alphabetical among sponsors is deliberate: it is the one order no sponsor can buy. + */ +export function pinSponsors(presets: CatalogPreset[]): CatalogPreset[] { + const sponsors = presets.filter(p => p.sponsor); + if (sponsors.length === 0) return presets; + sponsors.sort((a, b) => + SPONSOR_RANK[a.sponsor!] - SPONSOR_RANK[b.sponsor!] + || a.label.localeCompare(b.label, undefined, { sensitivity: "base" }) + || a.id.localeCompare(b.id)); + return [...sponsors, ...presets.filter(p => !p.sponsor)]; +} diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index d6065e0298..aa843b86fc 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -13,6 +13,7 @@ import { ProviderIcon } from "./ProviderRail"; import { Switch } from "../../ui"; import { IconChevron, IconTrash } from "../../icons"; import ProviderOverview from "./ProviderOverview"; +import type { CatalogPreset } from "../provider-catalog/provider-presets"; import type { ModelRow } from "../../pages/models-shared"; import ProviderModels from "./ProviderModels"; import ProviderUsage from "./ProviderUsage"; @@ -27,6 +28,7 @@ type Tab = "overview" | "models" | "usage" | "accounts" | "settings"; export default function ProviderDetails({ item, + preset, usageTotals, modelUsage, quotaReport, @@ -63,6 +65,7 @@ export default function ProviderDetails({ onRefreshQuota, }: { item: WorkspaceItem; + preset?: CatalogPreset; usageTotals?: ProviderUsageTotals; modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; @@ -264,6 +267,7 @@ export default function ProviderDetails({ {tab === "overview" && ( +
@@ -166,12 +171,6 @@ export default function ProviderOverview({
{t("modal.defaultModel")}
{item.defaultModel ?? }
- {item.note && ( -
-
{t("pws.cell.note")}
-
{item.note}
-
- )} {apiBase && (
@@ -250,6 +249,7 @@ export default function ProviderOverview({
)} +
+ ); } diff --git a/gui/src/components/provider-workspace/ProviderSponsor.tsx b/gui/src/components/provider-workspace/ProviderSponsor.tsx new file mode 100644 index 0000000000..6b52252b91 --- /dev/null +++ b/gui/src/components/provider-workspace/ProviderSponsor.tsx @@ -0,0 +1,44 @@ +import { useT } from "../../i18n/shared"; +import { IconExternal } from "../../icons"; +import type { WorkspaceItem } from "../../provider-workspace/catalog"; +import { matchingWorkspacePreset, type CatalogPreset } from "../provider-catalog/provider-presets"; + +function webLink(value?: string): string | undefined { + if (!value) return undefined; + try { + const url = new URL(value); + return (url.protocol === "https:" || url.protocol === "http:") && !url.username && !url.password + ? value : undefined; + } catch { return undefined; } +} + +/** Presentation only: sponsorship never changes routing or account state. */ +export default function ProviderSponsor({ item, preset }: { item: WorkspaceItem; preset?: CatalogPreset }) { + const t = useT(); + if (!preset?.sponsor || !matchingWorkspacePreset(item, [preset])) return null; + const brand = preset.id === "orcarouter" || preset.id === "orcarouter-oauth" + ? "OrcaRouter" : preset.id === "packycode" ? "PackyCode" : undefined; + if (!brand) return null; + const orca = brand === "OrcaRouter"; + const visit = webLink(preset.sponsorUrl); + const dashboard = webLink(preset.dashboardUrl); + + return
+
+
+ {brand} + {t("modal.badge.sponsor")} +
+

{t(orca ? "pws.sponsor.orcaTitle" : "pws.sponsor.packyTitle")}

+

{t(orca ? "pws.sponsor.orcaDescription" : "pws.sponsor.packyDescription")}

+
+ {(visit || dashboard) &&
+ {visit && + {t("pws.sponsor.visit", { provider: brand })}} + {dashboard && dashboard !== visit && + {t("pws.sponsor.console")}} +
} +
; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2a889b8f2b..3624c7ec36 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1876,6 +1876,13 @@ export const de: Record = { "modal.badge.direct": "Direct", "modal.badge.pool": "Pool", "modal.badge.free": "Kostenlos", + "modal.badge.sponsor": "Sponsor", + "pws.sponsor.orcaTitle": "Das passende Modell für jede Anfrage", + "pws.sponsor.orcaDescription": "Ein OpenAI-kompatibles Gateway mit adaptivem Routing und automatischem Failover.", + "pws.sponsor.packyTitle": "Claude Code, Codex und Gemini an einem Ort", + "pws.sponsor.packyDescription": "Ein API-Relay für Ihre KI-Programmierwerkzeuge. Starten Sie mit einem Token der Codex-Gruppe.", + "pws.sponsor.visit": "{provider} entdecken", + "pws.sponsor.console": "Konsole öffnen", "modal.invalidPreset": "Diese integrierte Anbietervorlage ist unvollständig. Starten Sie den Proxy neu und versuchen Sie es erneut.", "modal.freeTierTitle": "Kostenloser Tarif", "modal.freeTierDefault": "Kein API-Schlüssel nötig. Funktioniert sofort.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0cf7469f56..ddfa3a0bbb 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1133,6 +1133,13 @@ export const en = { "modal.badge.direct": "Direct", "modal.badge.pool": "Pool", "modal.badge.free": "Free", + "modal.badge.sponsor": "Sponsor", + "pws.sponsor.orcaTitle": "A model for every prompt", + "pws.sponsor.orcaDescription": "An OpenAI-compatible gateway with adaptive routing and automatic failover.", + "pws.sponsor.packyTitle": "Claude Code, Codex and Gemini in one place", + "pws.sponsor.packyDescription": "An API relay for your AI coding tools. Start with a Codex-group token.", + "pws.sponsor.visit": "Explore {provider}", + "pws.sponsor.console": "Open console", "modal.invalidPreset": "This built-in provider preset is incomplete. Restart the proxy and try again.", "modal.freeTierTitle": "Free tier", "modal.freeTierDefault": "No API key required. Works out of the box.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 576c3b7f23..537a7345c4 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1106,6 +1106,13 @@ export const fr: Record = { "modal.badge.direct": "Direct", "modal.badge.pool": "Groupe", "modal.badge.free": "Gratuit", + "modal.badge.sponsor": "Partenaire sponsor", + "pws.sponsor.orcaTitle": "Un modèle adapté à chaque requête", + "pws.sponsor.orcaDescription": "Une passerelle compatible OpenAI avec routage adaptatif et basculement automatique.", + "pws.sponsor.packyTitle": "Claude Code, Codex et Gemini au même endroit", + "pws.sponsor.packyDescription": "Un relais API pour vos outils de développement IA. Commencez avec un jeton du groupe Codex.", + "pws.sponsor.visit": "Découvrir {provider}", + "pws.sponsor.console": "Ouvrir la console", "modal.invalidPreset": "Ce préréglage de fournisseur intégré est incomplet. Redémarrez le proxy et réessayez.", "modal.freeTierTitle": "Offre gratuite", "modal.freeTierDefault": "Aucune clé API requise. Fonctionne immédiatement.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1e01aea545..a94ad676b0 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1048,6 +1048,13 @@ export const ja: Record = { "modal.badge.direct": "ダイレクト", "modal.badge.pool": "プール", "modal.badge.free": "無料", + "modal.badge.sponsor": "スポンサー", + "pws.sponsor.orcaTitle": "プロンプトに合うモデルを一か所で", + "pws.sponsor.orcaDescription": "自動ルーティングとフェイルオーバーに対応したOpenAI互換ゲートウェイです。", + "pws.sponsor.packyTitle": "Claude Code、Codex、Geminiを一か所で", + "pws.sponsor.packyDescription": "AIコーディングツール向けAPIリレーです。Codexグループのトークンで始められます。", + "pws.sponsor.visit": "{provider}を見る", + "pws.sponsor.console": "コンソールを開く", "modal.invalidPreset": "この組み込みプロバイダープリセットは不完全です。プロキシを再起動してもう一度お試しください。", "modal.freeTierTitle": "無料枠", "modal.freeTierDefault": "API キー不要です。そのまま利用できます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f1d20bf65e..ec673bf0a0 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1915,6 +1915,13 @@ export const ko: Record = { "modal.badge.direct": "Direct", "modal.badge.pool": "풀", "modal.badge.free": "무료", + "modal.badge.sponsor": "스폰서", + "pws.sponsor.orcaTitle": "요청에 맞는 모델을 한곳에서", + "pws.sponsor.orcaDescription": "자동 라우팅과 대체 모델 연결을 지원하는 OpenAI 호환 게이트웨이입니다.", + "pws.sponsor.packyTitle": "Claude Code, Codex, Gemini를 한곳에서", + "pws.sponsor.packyDescription": "여러 AI 코딩 도구에 연결하는 API 릴레이입니다. Codex 그룹 토큰으로 시작하세요.", + "pws.sponsor.visit": "{provider} 살펴보기", + "pws.sponsor.console": "콘솔 열기", "modal.invalidPreset": "내장 프로바이더 설정이 완전하지 않습니다. 프록시를 다시 시작한 뒤 재시도하세요.", "modal.freeTierTitle": "무료 티어", "modal.freeTierDefault": "API 키가 필요 없습니다. 바로 사용할 수 있습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c583bccb99..9d4b95bcf2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1103,6 +1103,13 @@ export const ru: Record = { "modal.badge.direct": "Прямой", "modal.badge.pool": "Пул", "modal.badge.free": "Бесплатно", + "modal.badge.sponsor": "Спонсор", + "pws.sponsor.orcaTitle": "Подходящая модель для каждого запроса", + "pws.sponsor.orcaDescription": "Совместимый с OpenAI шлюз с адаптивной маршрутизацией и автоматическим переключением.", + "pws.sponsor.packyTitle": "Claude Code, Codex и Gemini в одном месте", + "pws.sponsor.packyDescription": "API-ретранслятор для инструментов ИИ-разработки. Начните с токена группы Codex.", + "pws.sponsor.visit": "Подробнее о {provider}", + "pws.sponsor.console": "Открыть консоль", "modal.invalidPreset": "Этот встроенный пресет провайдера неполный. Перезапустите прокси и попробуйте ещё раз.", "modal.freeTierTitle": "Бесплатный тариф", "modal.freeTierDefault": "API-ключ не нужен. Работает из коробки.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca39260677..45be4dee20 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1122,6 +1122,13 @@ export const tr: Record = { "modal.badge.direct": "Doğrudan", "modal.badge.pool": "Havuz", "modal.badge.free": "Ücretsiz", + "modal.badge.sponsor": "Sponsor", + "pws.sponsor.orcaTitle": "Her istem için uygun model", + "pws.sponsor.orcaDescription": "Uyarlanabilir yönlendirme ve otomatik yük devretme sunan OpenAI uyumlu bir ağ geçidi.", + "pws.sponsor.packyTitle": "Claude Code, Codex ve Gemini tek yerde", + "pws.sponsor.packyDescription": "Yapay zekâ kodlama araçlarınız için API aktarma hizmeti. Codex grubu belirteciyle başlayın.", + "pws.sponsor.visit": "{provider} hakkında", + "pws.sponsor.console": "Konsolu aç", "modal.invalidPreset": "Bu yerleşik sağlayıcı ayarı eksik.", "modal.freeTierTitle": "Ücretsiz katman", "modal.freeTierDefault": "API anahtarı gerekmez. Doğrudan çalışır.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6462f6c4b5..1d0750b8cf 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -901,6 +901,13 @@ export const zhTW: Record = { "modal.badge.direct": "Direct", "modal.badge.pool": "帳號池", "modal.badge.free": "免費", + "modal.badge.sponsor": "贊助商", + "pws.sponsor.orcaTitle": "為每個提示選擇合適的模型", + "pws.sponsor.orcaDescription": "相容 OpenAI 的閘道,支援自適應路由和自動容錯移轉。", + "pws.sponsor.packyTitle": "一站連接 Claude Code、Codex 和 Gemini", + "pws.sponsor.packyDescription": "適用於 AI 程式開發工具的 API 中轉服務。使用 Codex 群組權杖開始。", + "pws.sponsor.visit": "了解 {provider}", + "pws.sponsor.console": "開啟控制台", "modal.invalidPreset": "此內建供應商預設不完整。請重新啟動代理後重試。", "modal.freeTierTitle": "免費層級", "modal.freeTierDefault": "無需 API 金鑰,開箱即用。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 685227abc0..591b28758a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1896,6 +1896,13 @@ export const zh: Record = { "modal.badge.direct": "直连", "modal.badge.pool": "账户池", "modal.badge.free": "免费", + "modal.badge.sponsor": "赞助商", + "pws.sponsor.orcaTitle": "为每个提示选择合适的模型", + "pws.sponsor.orcaDescription": "兼容 OpenAI 的网关,支持自适应路由和自动故障转移。", + "pws.sponsor.packyTitle": "一站连接 Claude Code、Codex 和 Gemini", + "pws.sponsor.packyDescription": "面向 AI 编程工具的 API 中转服务。使用 Codex 分组令牌开始。", + "pws.sponsor.visit": "了解 {provider}", + "pws.sponsor.console": "打开控制台", "modal.invalidPreset": "此内置提供方预设不完整。请重启代理后重试。", "modal.freeTierTitle": "免费层级", "modal.freeTierDefault": "无需 API 密钥,开箱即用。", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 78b2096d95..665044591f 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -2,6 +2,7 @@ import { usageSummary30dResourceKey } from "../usage-summary-resource"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell"; import ProviderDetails from "../components/provider-workspace/ProviderDetails"; +import { matchingWorkspacePreset, type CatalogPreset } from "../components/provider-catalog/provider-presets"; import { isAccountProvider, type WorkspaceProvider } from "../provider-workspace/catalog"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; @@ -290,13 +291,13 @@ export default function Providers({ apiBase }: { apiBase: string }) { // modal does not wait on a cold /api/provider-presets round-trip (~same key as // AddProviderModal). Prefetch usage too so the catalog does not paint alpha then // re-rank when the slow usage probe (~5s cold) finally returns. - useKeyedClientResource( + const presetResource = useKeyedClientResource( `add-provider-presets:${apiBase}`, [apiBase], async (signal) => { const res = await fetch(`${apiBase}/api/provider-presets`, { signal }); if (!res.ok) throw new Error(String(res.status)); - const data = await res.json() as { providers?: unknown[] }; + const data = await res.json() as { providers?: CatalogPreset[] }; return Array.isArray(data.providers) && data.providers.length > 0 ? data.providers : null; }, ); @@ -598,6 +599,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { ({ + id, label, adapter: "openai-chat", baseUrl: `https://${id}.example/v1`, auth: "key", + ...(sponsor ? { sponsor, sponsorUrl: `https://${id}.example/?utm_source=opencodex` } : {}), +}); + +test("sponsors are pinned first, Main before Standard, alphabetical within a tier", () => { + const input = [ + row("zeta", "Zeta"), + row("packycode", "PackyCode", "standard"), + row("alpha", "Alpha"), + row("orcarouter", "OrcaRouter", "standard"), + row("moon", "Moon Labs", "main"), + ]; + expect(pinSponsors(input).map(p => p.id)).toEqual(["moon", "orcarouter", "packycode", "zeta", "alpha"]); +}); + +test("alphabetical among sponsors ignores registry position and case", () => { + // Ids run z, y, x against labels bravo, ALPHA, charlie: sorting by id instead of label fails here. + const input = [row("z", "bravo", "standard"), row("y", "ALPHA", "standard"), row("x", "charlie", "standard")]; + expect(pinSponsors(input).map(p => p.id)).toEqual(["y", "z", "x"]); +}); + +test("with no sponsors the input order is returned as-is", () => { + const input = [row("zeta", "Zeta"), row("alpha", "Alpha")]; + expect(pinSponsors(input)).toBe(input); +}); diff --git a/gui/tests/provider-sponsor-overview.test.tsx b/gui/tests/provider-sponsor-overview.test.tsx new file mode 100644 index 0000000000..bdf0b30c49 --- /dev/null +++ b/gui/tests/provider-sponsor-overview.test.tsx @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { LanguageProvider } from "../src/i18n/provider"; +import ProviderOverview from "../src/components/provider-workspace/ProviderOverview"; +import ProviderSponsor from "../src/components/provider-workspace/ProviderSponsor"; +import { matchingWorkspacePreset, type CatalogPreset } from "../src/components/provider-catalog/provider-presets"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const orca: CatalogPreset = { + id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", auth: "key", + baseUrl: "https://api.orcarouter.ai/v1", sponsor: "standard", + sponsorUrl: "https://www.orcarouter.ai/?utm_source=opencodex&utm_medium=readme", + dashboardUrl: "https://www.orcarouter.ai/console", +}; +const packy: CatalogPreset = { + id: "packycode", label: "PackyCode", adapter: "openai-chat", auth: "key", + baseUrl: "https://cf.api.fan/v1", sponsor: "standard", + sponsorUrl: "https://www.packyapi.com/register?aff=k5KT", + dashboardUrl: "https://www.packyapi.com/register?aff=k5KT", +}; +const configured = (preset: CatalogPreset): WorkspaceItem => ({ + name: preset.id, adapter: preset.adapter, baseUrl: preset.baseUrl, authMode: preset.auth, +}); +function render(preset?: CatalogPreset, item = configured(orca)) { + return renderToStaticMarkup(); +} + +test("sponsor is matched by id, adapter and complete endpoint, tolerating trailing slash", () => { + const item = configured(orca); + const credentialEndpoint = new URL(item.baseUrl); + credentialEndpoint.username = "fixture-user"; + credentialEndpoint.password = "fixture-password"; + expect(matchingWorkspacePreset({ ...item, baseUrl: `${item.baseUrl}/` }, [orca])).toBe(orca); + for (const changed of [ + { name: "renamed" }, { adapter: "anthropic" }, { baseUrl: "https://other.example/v1" }, + { baseUrl: "https://api.orcarouter.ai/v2" }, { baseUrl: `${item.baseUrl}?key=secret` }, + { baseUrl: credentialEndpoint.href }, { baseUrl: "invalid" }, + ]) { + expect(matchingWorkspacePreset({ ...item, ...changed }, [orca])).toBeUndefined(); + expect(render(orca, { ...item, ...changed })).toBe(""); + } + expect(matchingWorkspacePreset(item, [])).toBeUndefined(); +}); + +test("key and OAuth sponsor presets render disclosed links, keeping affiliate parameters", () => { + // Only the key-auth `orcarouter` row carries `sponsor` in the registry today, so the oauth + // case is the property that an auth mode never suppresses the block — not a second pinned row. + for (const preset of [orca, { ...orca, id: "orcarouter-oauth", auth: "oauth" as const }]) { + const html = render(preset, configured(preset)); + expect(html).toContain("pws-sponsor-badge"); + expect(html).toContain("utm_source=opencodex&utm_medium=readme"); + expect(html).toContain('href="https://www.orcarouter.ai/console"'); + expect(html.match(/rel="noopener noreferrer"/g)).toHaveLength(2); + } +}); + +test("Packy preserves its affiliate link and does not repeat an identical console link", () => { + const html = render(packy, configured(packy)); + expect(html).toContain('href="https://www.packyapi.com/register?aff=k5KT"'); + expect(html.match(/ { + expect(render()).toBe(""); + expect(render({ ...orca, sponsor: undefined })).toBe(""); + const html = render({ ...orca, sponsorUrl: "javascript:alert(1)", dashboardUrl: "data:text/html,bad" }); + expect(html).not.toContain(" { + const note = "A provider limitation that must remain visible. https://example.test/details"; + const html = renderToStaticMarkup(); + expect(html.split(note)).toHaveLength(2); + expect(html.indexOf("pws-notes-section")).toBeLessThan(html.indexOf("pws-overview-sidebar")); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 53a8e65898..8587431f7d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1156,6 +1156,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index d2f24d0b8d..6477694a49 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -204,7 +204,8 @@ async function presets(argv: string[], deps: RuntimeApiDeps): Promise { const rows = Array.isArray(result) ? result : result.providers ?? []; printData(result, wantsJson, rows.map(row => { const record = row as Record; - return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}`.trimEnd(); + const sponsor = record.sponsor ? ` (sponsor: ${String(record.sponsor)})` : ""; + return `${String(record.id ?? record.name ?? "?")} ${String(record.label ?? record.adapter ?? "")}${sponsor}`.trimEnd(); })); } diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 02852fce39..67e6c0522e 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -80,6 +80,10 @@ export interface DerivedProviderPreset { keyOptional?: boolean; /** Free pricing (may still require a key). */ freeTier?: boolean; + /** Sponsor tier from SPONSORS.md; the picker pins and labels these rows. */ + sponsor?: "main" | "standard"; + /** Sponsor landing URL (with its tracking parameters), for the picker's row link. */ + sponsorUrl?: string; /** * Endpoint picker rows (token plan / payg / custom). When present, the add-provider * form shows a dropdown; `custom` reveals a free-text base URL field. @@ -607,6 +611,7 @@ function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset { ...(entry.note ? { note: entry.note } : {}), ...(entry.keyOptional ? { keyOptional: true } : {}), ...(entry.freeTier ? { freeTier: true } : {}), + ...(entry.sponsor ? { sponsor: entry.sponsor.tier, sponsorUrl: entry.sponsor.url } : {}), ...(entry.baseUrlChoices ? { baseUrlChoices: entry.baseUrlChoices.map(c => ({ ...c })) } : {}), }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 6d84efe529..50f082f384 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -175,6 +175,13 @@ export interface ProviderRegistryEntry { staticHeaders?: Record; modelSuffixBracketStrip?: boolean; featured?: boolean; + /** + * Paid provider sponsorship under SPONSORS.md. `main` is reserved for model developers, + * `standard` for relays and gateways. The picker pins sponsor rows first (alphabetical among + * themselves) and labels them; nothing else reads this field. Routing, failover, quota, and + * defaults never consult it — that boundary is what SPONSORS.md promises users. + */ + sponsor?: { tier: "main" | "standard"; url: string }; dashboardPreset?: boolean; note?: string; dashboardUrl?: string; @@ -1918,6 +1925,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console", // The catalog is public, so a successful /models probe cannot validate a submitted key. apiKeyValidation: "unknown", + // Standard sponsor under SPONSORS.md (agreement signed 2026-09-07). Pins the row in the + // picker and adds the chip; nothing about routing or defaults changes. + sponsor: { tier: "standard", url: "https://www.orcarouter.ai/?utm_source=opencodex&utm_medium=readme" }, defaultModel: "openai/gpt-5.5", models: ORCAROUTER_MODELS, liveModels: true, diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index d6e66375b7..2c21897855 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -319,6 +319,14 @@ and catalog invariants documented in this folder rather than inventing parallel ## Dashboard surfaces +Provider Overview consumes the existing shared `add-provider-presets` resource for sponsor +presentation. `matchingWorkspacePreset` requires the configured id, adapter and normalized +endpoint to match; a custom endpoint or absent sponsor metadata suppresses the introduction. +`ProviderSponsor` keeps localized promotional copy and outbound HTTP(S) links separate from +operator notes. Notes remain complete and editable once in the main column; stats and current +account quota remain in the side column. This presentation does not write provider configuration +or participate in routing. + The sidebar exposes eleven pages (`gui/src/App.tsx` `NAV`). Several are workspace shells rather than single forms, and the shell pattern is the part worth keeping stable: diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index da6be012bc..f62377f3e7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -991,6 +991,7 @@ "sidecar-tracker.test.ts": "vision", "skill-ocx.test.ts": "ci-workflows", "slug-codec.test.ts": "codex-integration", + "sponsor-presets.test.ts": "providers", "sse-client-frame-bounds.test.ts": "responses", "sse-decoder.test.ts": "responses", "sse-failed-tail.test.ts": "responses", diff --git a/tests/providers/sponsor-presets.test.ts b/tests/providers/sponsor-presets.test.ts new file mode 100644 index 0000000000..c7e28e9e16 --- /dev/null +++ b/tests/providers/sponsor-presets.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { deriveProviderPresets } from "../../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; + +/** + * The registry `sponsor` field is the only thing that marks a paid sponsor, and SPONSORS.md + * promises it changes nothing but picker placement and a label. These pin the wire shape the + * dashboard and `ocx provider presets` read, and that every sponsor entry carries a landing URL. + */ +describe("sponsor presets", () => { + test("registry sponsor entries surface tier and URL on the derived preset", () => { + const sponsors = PROVIDER_REGISTRY.filter(entry => entry.sponsor); + const presets = deriveProviderPresets(); + for (const entry of sponsors) { + const preset = presets.find(p => p.id === entry.id); + expect(preset, entry.id).toBeDefined(); + expect(preset?.sponsor).toBe(entry.sponsor!.tier); + expect(preset?.sponsorUrl).toBe(entry.sponsor!.url); + expect(entry.sponsor!.url.startsWith("https://")).toBe(true); + } + }); + + test("non-sponsor presets carry no sponsor keys at all", () => { + const sponsorIds = new Set(PROVIDER_REGISTRY.filter(entry => entry.sponsor).map(entry => entry.id)); + for (const preset of deriveProviderPresets()) { + if (sponsorIds.has(preset.id)) continue; + expect("sponsor" in preset, preset.id).toBe(false); + expect("sponsorUrl" in preset, preset.id).toBe(false); + } + }); + + test("derived preset order is registry order — pinning is the picker's job", () => { + const ids = deriveProviderPresets().map(p => p.id).filter(id => id !== "custom"); + const registryOrder = PROVIDER_REGISTRY.map(e => e.id).filter(id => ids.includes(id)); + const seen = new Set(); + const deduped = registryOrder.filter(id => (seen.has(id) ? false : (seen.add(id), true))); + expect(ids).toEqual(deduped); + }); +});