feat(agent): AI model config with admin controls (#48)

* feat(agent): add AI model config with admin controls

per-user model selection with admin cost ceiling and
toggle. models filter/sort by output token cost. adds
usage tracking, provider icons, and settings UI.

* docs: add openclaw discord integration notes

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
Nicholai 2026-02-06 20:36:31 -07:00 committed by GitHub
parent f8faabd508
commit e3b708317c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 8968 additions and 76 deletions

View File

@ -0,0 +1,179 @@
Discord Integration
===
how OpenClaw connects to Discord, processes inbound messages, delivers replies, and exposes guild operations to the agent. covers the monitoring pipeline, the outbound delivery system, thread and DM handling, slash commands, and the action gating model. relevant context for Compass because Discord is one of OpenClaw's most complete channel integrations — around 70 files and 10k lines — and its architecture illustrates patterns that any new channel (or any Compass integration with OpenClaw's messaging layer) would need to follow.
why channels are structured this way
---
OpenClaw supports many messaging surfaces — Discord, Telegram, WhatsApp, Slack, Signal, a web UI, and several extension channels. each has wildly different capabilities and constraints: Discord has slash commands, threads, reactions, and embeds; WhatsApp has none of these but supports end-to-end encryption; Telegram sits somewhere in between. the challenge is giving the agent access to channel-specific features while keeping the core agent loop channel-agnostic.
the solution is a three-layer architecture. the **monitor layer** receives events from Discord and normalizes them into a common inbound format. the **outbound layer** takes the agent's response and formats it for Discord's specific constraints (character limits, chunking, embeds). the **channel plugin layer** provides shared abstractions (send text, send media, send poll) that the agent's tool system calls without knowing which channel it's talking to.
this separation matters because it means the agent doesn't need Discord-specific knowledge. it calls `message(action="send", to="user:123", message="hello")` and the routing layer figures out that this is a Discord DM, resolves the right bot account, and calls the Discord API. the agent's system prompt mentions available channels but doesn't contain Discord API details.
the monitoring pipeline
---
the entry point is `monitorDiscordProvider()` in `src/discord/monitor/provider.ts`. initialization follows a specific sequence because order matters — you can't register event listeners before the client is ready, and you can't deploy slash commands before you've validated the application ID.
```
monitorDiscordProvider()
→ resolveDiscordAccount() — load per-account config
→ fetchDiscordApplicationId() — validate bot setup
→ new Client() — create Carbon discord.js wrapper
→ registerDiscordListener() — attach event handlers
→ deployDiscordCommands() — register native slash commands
```
the client is created with gateway intents that control which events Discord sends. the base set is always: Guilds, GuildMessages, MessageContent, DirectMessages, GuildMessageReactions, DirectMessageReactions. two additional intents — GuildPresences and GuildMembers — require explicit opt-in because Discord gates these behind a privileged intent approval for bots in more than 100 guilds. this is a Discord-imposed constraint, not an OpenClaw choice.
inbound message flow
---
a Discord message passes through four stages before reaching the agent.
**stage 1: event listener.** `DiscordMessageListener` in `src/discord/monitor/listeners.ts` wraps Carbon's `MessageCreateListener`. when a message arrives, it gets queued with slow-listener detection — if the handler takes too long, the system flags it rather than blocking other events.
**stage 2: debouncing.** the message handler in `src/discord/monitor/message-handler.ts` batches rapid-fire messages from the same user in the same channel. the debounce key is `discord:{accountId}:{channelId}:{authorId}`. this exists because Discord users commonly split a thought across multiple quick messages. without debouncing, the agent would receive three separate prompts and try to respond to each one independently, producing three partial replies instead of one coherent response. when multiple messages arrive in the debounce window, their text gets concatenated and the last message's metadata (attachments, reference info) is preserved.
**stage 3: preflight.** `message-handler.preflight.ts` validates whether the message should be processed at all. this is where most messages get dropped — bot messages (to prevent loops), messages from ungated guilds, messages in disallowed channels, DMs when DM policy is disabled. the preflight also resolves PluralKit proxy identity (if enabled), checks mention requirements, and extracts thread context. the output is either a `DiscordMessagePreflightContext` or null.
the PluralKit integration deserves a mention: PluralKit is a Discord bot used by plural systems to proxy messages under different identities. OpenClaw can optionally resolve the actual proxied author, which means the agent sees the correct identity rather than the PluralKit bot. this is configured per-account and requires the PluralKit system ID.
**stage 4: dispatch.** `message-handler.process.ts` sends the validated message to the agent via `dispatchInboundMessage()`. it starts a typing indicator (so the user sees "OpenClaw is typing..."), manages ack reactions (a configurable emoji the bot adds to acknowledge receipt, removed after the reply arrives), and routes the agent's response back through the outbound pipeline.
outbound delivery
---
the outbound pipeline in `src/discord/send.outbound.ts` and `reply-delivery.ts` handles the translation from "agent wants to say X" to "Discord receives properly formatted messages."
discord imposes a 2000-character limit per message. the pipeline chunks responses by both character count and line count (default 17 lines per message). the line limit exists because a single 2000-character message is a wall of text on mobile — breaking it into shorter messages improves readability even when the character limit isn't reached.
the first chunk gets a reply-to reference (so it threads under the user's message in Discord's UI). subsequent chunks are sent as follow-up messages without the reference. media attachments go with the first message when possible; additional media gets separate messages.
markdown formatting gets adjusted for Discord's flavor. tables, which Discord doesn't render natively, get converted to a format that displays reasonably in a monospace code block. this is a lossy transformation but better than sending raw pipe-delimited tables that render as garbled text.
slash commands
---
native slash commands are built in `src/discord/monitor/native-command.ts`. OpenClaw maps its internal command registry to Discord's `ApplicationCommand` format, which means commands registered in OpenClaw automatically appear as slash commands in the Discord UI with proper argument types, descriptions, and autocomplete.
the implementation handles a subtle Discord constraint: interactions expire after 3 seconds. if the agent takes longer than that to respond (which it almost always does), the interaction becomes invalid. the system wraps all interaction replies in `safeDiscordInteractionCall()`, which catches Discord error 10062 (Unknown interaction) and degrades gracefully — typically by sending a follow-up message to the channel instead of replying to the interaction.
for commands with many options, the system renders button menus instead of requiring the user to type a value. each button encodes its context in the custom ID string: `cmdarg:command=X;arg=Y;value=Z;user=U`. this is stateless — no server-side session is needed to handle the button click, because all the necessary context is embedded in the button itself. the tradeoff is that custom IDs have a 100-character limit, which constrains how much context can be embedded.
threads and DMs
---
**threads** are detected by channel type (PublicThread, PrivateThread, AnnouncementThread). OpenClaw supports two thread modes: responding in existing threads (always), and auto-creating threads for new conversations (`channelConfig.autoThread=true`). auto-thread creation takes the first message's text as the thread name (sanitized, with a fallback to "Thread {messageId}").
thread starters are cached because Discord's API doesn't include the original message when delivering events in a thread. the cache maps message ID to text/author/timestamp, which lets the agent see the context that started the conversation.
threads inherit their parent channel's permissions and bindings, but the session key includes the thread ID — so each thread gets its own conversation state. this means the agent maintains separate context per thread, which is the correct behavior for a guild where multiple threads discuss different topics simultaneously.
**DMs** are governed by a three-tier policy:
- `"open"`: accept all DMs. simplest setup, appropriate for personal bots.
- `"pairing"`: require an authentication code before accepting. the bot sends a code, the user provides it through another channel, and DMs are unlocked. this prevents random users from consuming agent resources.
- `"disabled"`: reject all DMs.
group DMs are opt-in separately (`dm.groupEnabled`) because they have different trust characteristics — a group DM includes multiple users, so the agent's responses are visible to people who may not have been explicitly authorized.
multi-account support
---
OpenClaw can run multiple Discord bots simultaneously via `discord.accounts.{id}`. each account has its own token, configuration, event listeners, and gateway connection. a gateway registry (`src/discord/monitor/gateway-registry.ts`) maps account IDs to gateway plugins so agent tools resolve the correct bot when performing operations.
the default account ID is `"default"`, used when no specific account is referenced. this means single-bot deployments don't need to think about accounts at all — the configuration is backward-compatible.
multi-account is useful for deployments where different guilds need different bot identities (different names, avatars, permission sets) while sharing the same underlying agent.
routing
---
when a Discord message passes preflight, it gets routed to an agent via `resolveAgentRoute()`. the routing key includes:
- `channel: "discord"`
- `accountId`
- `peer: { kind: "dm"|"group"|"channel", id }` — who sent it
- `parentPeer` — for threads, the parent channel
- `guildId` — guild context
routing returns an agent ID, session key, and binding info. the binding system allows per-guild and per-channel routing — guild A can route to agent "support" while guild B routes to agent "main". channel-level bindings override guild-level, which overrides the account default.
this cascading resolution is important for multi-guild deployments where different communities need different agent behavior.
allowlists and gating
---
access control happens through allowlists that support multiple ID formats:
- bare numeric IDs
- `user:` prefixed
- `discord:` prefixed
- `pk:` (PluralKit system IDs)
- slug-based lookups (normalized channel/guild names)
the resolution cascades: per-channel config overrides per-guild, which overrides account defaults. this means you can have a guild-wide allowlist with specific channels that have tighter or looser restrictions.
agent actions
---
the Discord channel exposes over 30 actions to the agent through the tool system. these are defined in `src/agents/tools/discord-actions*.ts` and gated by `DiscordActionConfig`, a per-account configuration that controls which categories of actions are available.
the categories:
- **messaging**: send, read, edit, delete, pin messages
- **reactions**: add, remove, read reactions
- **threads**: create, list, reply in threads
- **guild info**: member lookup, role info, channel info
- **moderation**: timeout, kick, ban members
- **channel management**: create, edit, delete, move channels, manage permissions
- **rich content**: polls, embeds, file attachments, emoji and sticker uploads
- **presence**: set bot status and activity
- **search**: message search with query filters
the gating is coarse-grained by category. you can disable all moderation actions for an account while keeping messaging enabled. this is a practical choice — fine-grained per-action gating would create a configuration surface too large to manage, while category-level gating covers the real use cases (a community bot that shouldn't moderate, a personal bot that should have full access).
the action gating is independent of Discord's own permission system. even if an action is enabled in OpenClaw's config, it will fail if the bot's Discord role doesn't have the necessary permissions. Discord error 50013 (Missing Permissions) is caught and logged without crashing the handler.
error handling
---
the integration is designed to survive Discord's various failure modes without taking down the gateway.
**retry with backoff**: `createDiscordRetryRunner()` wraps API calls with exponential backoff. configured per-account. this handles transient Discord API errors (rate limits, 5xx responses) without manual intervention.
**interaction expiry**: the 3-second interaction timeout is handled gracefully. rather than crashing when the agent takes longer to respond, the system catches the expiry and falls back to a regular channel message.
**permission errors**: Discord 50013 (missing permissions) and 50007 (can't DM user) are caught, logged, and don't propagate. the agent receives an error result from the tool call, which lets it explain the failure to the user rather than silently failing.
**slow-listener detection**: event listeners that take too long get flagged. this prevents a single slow handler from blocking the event queue and causing the bot to appear unresponsive.
**debounce resilience**: the inbound debouncer preserves the last message's metadata across batches. if something goes wrong mid-batch, the system still has enough context to process at least the final message.
relevance to compass
---
the Discord integration illustrates patterns that would apply to any Compass integration with OpenClaw's messaging layer:
**the three-layer architecture** (monitor, outbound, channel plugin) is how all channels work. if Compass adds its own messaging surface (in-app notifications, a chat panel, a Slack integration), it would follow the same pattern — a monitor that receives events, an outbound layer that formats responses, and a channel plugin that provides the shared abstractions.
**the allowlist and routing model** shows how multi-tenant deployments work. if Compass serves multiple organizations, each would need its own routing bindings and access control, similar to how Discord guilds are isolated.
**the action gating model** demonstrates how to give the agent channel-specific capabilities without hardcoding them. Compass could expose project-specific actions (create task, update schedule, assign member) through the same tool system, gated by user role or project permissions.
**the debouncing pattern** is worth adopting for any real-time messaging interface. users don't compose complete thoughts in single messages, and an agent that responds to each fragment independently produces a poor experience.

View File

@ -6,6 +6,7 @@ export default defineConfig({
"./src/db/schema-netsuite.ts",
"./src/db/schema-plugins.ts",
"./src/db/schema-agent.ts",
"./src/db/schema-ai-config.ts",
],
out: "./drizzle",
dialect: "sqlite",

View File

@ -0,0 +1,26 @@
CREATE TABLE `agent_config` (
`id` text PRIMARY KEY NOT NULL,
`model_id` text NOT NULL,
`model_name` text NOT NULL,
`provider` text NOT NULL,
`prompt_cost` text NOT NULL,
`completion_cost` text NOT NULL,
`context_length` integer NOT NULL,
`updated_by` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`updated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE TABLE `agent_usage` (
`id` text PRIMARY KEY NOT NULL,
`conversation_id` text NOT NULL,
`user_id` text NOT NULL,
`model_id` text NOT NULL,
`prompt_tokens` integer DEFAULT 0 NOT NULL,
`completion_tokens` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`estimated_cost` text NOT NULL,
`created_at` text NOT NULL,
FOREIGN KEY (`conversation_id`) REFERENCES `agent_conversations`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);

11
drizzle/0014_new_giant_girl.sql Executable file
View File

@ -0,0 +1,11 @@
CREATE TABLE `user_model_preference` (
`user_id` text PRIMARY KEY NOT NULL,
`model_id` text NOT NULL,
`prompt_cost` text NOT NULL,
`completion_cost` text NOT NULL,
`updated_at` text NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
ALTER TABLE `agent_config` ADD `max_cost_per_million` text;--> statement-breakpoint
ALTER TABLE `agent_config` ADD `allow_user_selection` integer DEFAULT 1 NOT NULL;

3098
drizzle/meta/0013_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

3172
drizzle/meta/0014_snapshot.json Executable file

File diff suppressed because it is too large Load Diff

View File

@ -92,6 +92,20 @@
"when": 1770389906158,
"tag": "0012_chilly_lake",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1770427704637,
"tag": "0013_curved_ricochet",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1770431392946,
"tag": "0014_new_giant_girl",
"breakpoints": true
}
]
}

1
public/providers/amazon.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

1
public/providers/anthropic.svg Executable file
View File

@ -0,0 +1 @@
<svg height="2500" viewBox="0 6.603 1192.672 1193.397" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m233.96 800.215 234.684-131.678 3.947-11.436-3.947-6.363h-11.436l-39.221-2.416-134.094-3.624-116.296-4.832-112.67-6.04-28.35-6.04-26.577-35.035 2.738-17.477 23.84-16.027 34.147 2.98 75.463 5.155 113.235 7.812 82.147 4.832 121.692 12.644h19.329l2.738-7.812-6.604-4.832-5.154-4.832-117.182-79.41-126.845-83.92-66.443-48.321-35.92-24.484-18.12-22.953-7.813-50.093 32.618-35.92 43.812 2.98 11.195 2.98 44.375 34.147 94.792 73.37 123.786 91.167 18.12 15.06 7.249-5.154.886-3.624-8.135-13.61-67.329-121.692-71.838-123.785-31.974-51.302-8.456-30.765c-2.98-12.645-5.154-23.275-5.154-36.242l37.127-50.416 20.537-6.604 49.53 6.604 20.86 18.121 30.765 70.39 49.852 110.818 77.315 150.684 22.631 44.698 12.08 41.396 4.51 12.645h7.813v-7.248l6.362-84.886 11.759-104.215 11.436-134.094 3.946-37.772 18.685-45.262 37.127-24.482 28.994 13.852 23.839 34.148-3.303 22.067-14.174 92.134-27.785 144.323-18.121 96.644h10.55l12.08-12.08 48.887-64.913 82.147-102.685 36.242-40.752 42.282-45.02 27.14-21.423h51.303l37.772 56.135-16.913 57.986-52.832 67.007-43.812 56.779-62.82 84.563-39.22 67.651 3.623 5.396 9.343-.886 141.906-30.201 76.671-13.852 91.49-15.705 41.396 19.329 4.51 19.65-16.269 40.189-97.852 24.16-114.764 22.954-170.9 40.43-2.093 1.53 2.416 2.98 76.993 7.248 32.94 1.771h80.617l150.12 11.195 39.222 25.933 23.517 31.732-3.946 24.16-60.403 30.766-81.503-19.33-190.228-45.26-65.235-16.27h-9.02v5.397l54.362 53.154 99.624 89.96 124.752 115.973 6.362 28.671-16.027 22.63-16.912-2.415-109.611-82.47-42.282-37.127-95.758-80.618h-6.363v8.456l22.067 32.296 116.537 175.167 6.04 53.719-8.456 17.476-30.201 10.55-33.181-6.04-68.215-95.758-70.39-107.84-56.778-96.644-6.926 3.947-33.503 360.886-15.705 18.443-36.243 13.852-30.201-22.953-16.027-37.127 16.027-73.37 19.329-95.758 15.704-76.107 14.175-94.55 8.456-31.41-.563-2.094-6.927.886-71.275 97.852-108.402 146.497-85.772 91.812-20.537 8.134-35.597-18.443 3.301-32.94 19.893-29.315 118.712-151.007 71.597-93.583 46.228-54.04-.322-7.813h-2.738l-315.302 204.725-56.135 7.248-24.16-22.63 2.98-37.128 11.435-12.08 94.792-65.236-.322.323z" fill="#d97757"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

1
public/providers/deepseek.svg Executable file
View File

@ -0,0 +1 @@
<svg fill="none" height="1320" viewBox="3.771 6.973 23.993 17.652" width="2500" xmlns="http://www.w3.org/2000/svg"><path d="m27.501 8.469c-.252-.123-.36.111-.508.23-.05.04-.093.09-.135.135-.368.395-.797.652-1.358.621-.821-.045-1.521.213-2.14.842-.132-.776-.57-1.238-1.235-1.535-.349-.155-.701-.309-.944-.645-.171-.238-.217-.504-.303-.765-.054-.159-.108-.32-.29-.348-.197-.031-.274.135-.352.273-.31.567-.43 1.192-.419 1.825.028 1.421.628 2.554 1.82 3.36.136.093.17.186.128.321-.081.278-.178.547-.264.824-.054.178-.135.217-.324.14a5.448 5.448 0 0 1 -1.719-1.169c-.848-.82-1.614-1.726-2.57-2.435-.225-.166-.449-.32-.681-.467-.976-.95.128-1.729.383-1.82.267-.096.093-.428-.77-.424s-1.653.293-2.659.677a2.782 2.782 0 0 1 -.46.135 9.554 9.554 0 0 0 -2.853-.1c-1.866.21-3.356 1.092-4.452 2.6-1.315 1.81-1.625 3.87-1.246 6.018.399 2.261 1.552 4.136 3.326 5.601 1.837 1.518 3.955 2.262 6.37 2.12 1.466-.085 3.1-.282 4.942-1.842.465.23.952.322 1.762.392.623.059 1.223-.031 1.687-.127.728-.154.677-.828.414-.953-2.132-.994-1.665-.59-2.09-.916 1.084-1.285 2.717-2.619 3.356-6.94.05-.343.007-.558 0-.837-.004-.168.034-.235.228-.254a4.084 4.084 0 0 0 1.529-.47c1.382-.757 1.938-1.997 2.07-3.485.02-.227-.004-.463-.243-.582zm-12.041 13.391c-2.067-1.627-3.07-2.162-3.483-2.138-.387.021-.318.465-.233.754.089.285.205.482.368.732.113.166.19.414-.112.598-.666.414-1.823-.139-1.878-.166-1.347-.793-2.473-1.842-3.267-3.276-.765-1.38-1.21-2.861-1.284-4.441-.02-.383.093-.518.472-.586a4.692 4.692 0 0 1 1.514-.04c2.109.31 3.905 1.255 5.41 2.749.86.853 1.51 1.871 2.18 2.865.711 1.057 1.478 2.063 2.454 2.887.343.289.619.51.881.672-.792.088-2.117.107-3.022-.61zm.99-6.38a.304.304 0 1 1 .609 0c0 .17-.136.304-.306.304a.3.3 0 0 1 -.303-.305zm3.077 1.581c-.197.08-.394.15-.584.159a1.246 1.246 0 0 1 -.79-.252c-.27-.227-.463-.354-.546-.752a1.752 1.752 0 0 1 .016-.582c.07-.324-.008-.531-.235-.72-.187-.155-.422-.196-.682-.196a.551.551 0 0 1 -.252-.078c-.108-.055-.197-.19-.112-.356.027-.053.159-.183.19-.207.352-.201.758-.135 1.134.016.349.142.611.404.99.773.388.448.457.573.678.906.174.264.333.534.441.842.066.192-.02.35-.248.448z" fill="#4d6bfe"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

1
public/providers/google.svg Executable file
View File

@ -0,0 +1 @@
<svg id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-380.2 274.7 65.7 65.8"><style>.st0{fill:#e0e0e0}.st1{fill:#fff}.st2{clip-path:url(#SVGID_2_);fill:#fbbc05}.st3{clip-path:url(#SVGID_4_);fill:#ea4335}.st4{clip-path:url(#SVGID_6_);fill:#34a853}.st5{clip-path:url(#SVGID_8_);fill:#4285f4}</style><circle class="st0" cx="-347.3" cy="307.6" r="32.9"/><circle class="st1" cx="-347.3" cy="307.1" r="32.4"/><g><defs><path id="SVGID_1_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_2_"><use xlink:href="#SVGID_1_" overflow="visible"/></clipPath><path class="st2" d="M-370.8 320.3v-26l17 13z"/><defs><path id="SVGID_3_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_4_"><use xlink:href="#SVGID_3_" overflow="visible"/></clipPath><path class="st3" d="M-370.8 294.3l17 13 7-6.1 24-3.9v-14h-48z"/><g><defs><path id="SVGID_5_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_6_"><use xlink:href="#SVGID_5_" overflow="visible"/></clipPath><path class="st4" d="M-370.8 320.3l30-23 7.9 1 10.1-15v48h-48z"/></g><g><defs><path id="SVGID_7_" d="M-326.3 303.3h-20.5v8.5h11.8c-1.1 5.4-5.7 8.5-11.8 8.5-7.2 0-13-5.8-13-13s5.8-13 13-13c3.1 0 5.9 1.1 8.1 2.9l6.4-6.4c-3.9-3.4-8.9-5.5-14.5-5.5-12.2 0-22 9.8-22 22s9.8 22 22 22c11 0 21-8 21-22 0-1.3-.2-2.7-.5-4z"/></defs><clipPath id="SVGID_8_"><use xlink:href="#SVGID_7_" overflow="visible"/></clipPath><path class="st5" d="M-322.8 331.3l-31-24-4-3 35-10z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

1
public/providers/meta.svg Executable file
View File

@ -0,0 +1 @@
<svg viewBox="0 0 287.59000000000003 191" xmlns="http://www.w3.org/2000/svg" width="2500" height="1660"><linearGradient id="a" gradientTransform="matrix(1 0 0 -1 0 192)" gradientUnits="userSpaceOnUse" x1="62.34" x2="260.34" y1="101.45" y2="91.45"><stop offset="0" stop-color="#0064e1"/><stop offset=".4" stop-color="#0064e1"/><stop offset=".83" stop-color="#0073ee"/><stop offset="1" stop-color="#0082fb"/></linearGradient><linearGradient id="b" gradientTransform="matrix(1 0 0 -1 0 192)" gradientUnits="userSpaceOnUse" x1="41.42" x2="41.42" y1="53" y2="126"><stop offset="0" stop-color="#0082fb"/><stop offset="1" stop-color="#0064e0"/></linearGradient><path d="M31.06 126c0 11 2.41 19.41 5.56 24.51A19 19 0 0 0 53.19 160c8.1 0 15.51-2 29.79-21.76 11.44-15.83 24.92-38 34-52l15.36-23.6c10.67-16.39 23-34.61 37.18-47C181.07 5.6 193.54 0 206.09 0c21.07 0 41.14 12.21 56.5 35.11 16.81 25.08 25 56.67 25 89.27 0 19.38-3.82 33.62-10.32 44.87C271 180.13 258.72 191 238.13 191v-31c17.63 0 22-16.2 22-34.74 0-26.42-6.16-55.74-19.73-76.69-9.63-14.86-22.11-23.94-35.84-23.94-14.85 0-26.8 11.2-40.23 31.17-7.14 10.61-14.47 23.54-22.7 38.13l-9.06 16c-18.2 32.27-22.81 39.62-31.91 51.75C84.74 183 71.12 191 53.19 191c-21.27 0-34.72-9.21-43-23.09C3.34 156.6 0 141.76 0 124.85z" fill="#0081fb"/><path d="M24.49 37.3C38.73 15.35 59.28 0 82.85 0c13.65 0 27.22 4 41.39 15.61 15.5 12.65 32 33.48 52.63 67.81l7.39 12.32c17.84 29.72 28 45 33.93 52.22 7.64 9.26 13 12 19.94 12 17.63 0 22-16.2 22-34.74l27.4-.86c0 19.38-3.82 33.62-10.32 44.87C271 180.13 258.72 191 238.13 191c-12.8 0-24.14-2.78-36.68-14.61-9.64-9.08-20.91-25.21-29.58-39.71L146.08 93.6c-12.94-21.62-24.81-37.74-31.68-45-7.4-7.89-16.89-17.37-32.05-17.37-12.27 0-22.69 8.61-31.41 21.78z" fill="url(#a)"/><path d="M82.35 31.23c-12.27 0-22.69 8.61-31.41 21.78C38.61 71.62 31.06 99.34 31.06 126c0 11 2.41 19.41 5.56 24.51l-26.48 17.4C3.34 156.6 0 141.76 0 124.85 0 94.1 8.44 62.05 24.49 37.3 38.73 15.35 59.28 0 82.85 0z" fill="url(#b)"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

1
public/providers/microsoft.svg Executable file
View File

@ -0,0 +1 @@
<svg enable-background="new 0 0 2499.6 2500" viewBox="0 0 2499.6 2500" xmlns="http://www.w3.org/2000/svg"><path d="m1187.9 1187.9h-1187.9v-1187.9h1187.9z" fill="#f1511b"/><path d="m2499.6 1187.9h-1188v-1187.9h1187.9v1187.9z" fill="#80cc28"/><path d="m1187.9 2500h-1187.9v-1187.9h1187.9z" fill="#00adef"/><path d="m2499.6 2500h-1188v-1187.9h1187.9v1187.9z" fill="#fbbc09"/></svg>

After

Width:  |  Height:  |  Size: 378 B

1
public/providers/mistral.svg Executable file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="#fff" d="M0 0h192.756v192.756H0V0z"/><path d="M35.69 117.324v-16.918a4.168 4.168 0 0 1 4.169-4.168c2.302 0 4.642 1.865 4.642 4.168v16.918h9.219l-.003-17.627c0-7.392-6.465-13.384-13.857-13.384a13.337 13.337 0 0 0-8.748 3.253 13.333 13.333 0 0 0-8.748-3.253c-7.392 0-13.857 5.992-13.857 13.384l-.003 17.627h9.219v-16.918c0-2.303 2.339-4.168 4.641-4.168a4.167 4.167 0 0 1 4.169 4.168v16.918h9.157zM62.97 85.961a5.726 5.726 0 1 0 0-11.452 5.726 5.726 0 0 0 0 11.452zM58.005 87.269v30.024h21.396c5.556 0 14.658-5.201 14.658-16.312H84.011v2.719c0 2.838-2.482 5.201-5.91 5.201H67.935V87.269h-9.93z"/><path d="M126.947 117.293H114.39h-.354c-13.477 0-16.787-10.047-16.787-14.658v-6.146H86.494c-3.428 0-5.91 2.365-5.91 5.201v2.719H70.536c0-11.111 9.102-16.312 14.658-16.312H97.25V77.812h9.93v10.284h7.803v8.392h-7.803v5.201c0 2.838.473 7.211 9.812 7.211l.029-8.494c0-7.392 7.176-13.384 14.566-13.384 1.08 0 1.773.128 2.779.369l-.049 9.865a4.146 4.146 0 0 0-2.73-1.018c-2.301 0-4.641 1.865-4.641 4.168v16.887h.001z"/><path d="M165.346 102.281c0-8.817-7.148-15.965-15.965-15.965-8.818 0-15.965 7.148-15.965 15.965 0 8.816 7.146 15.965 15.965 15.965.848 0 1.678-.066 2.49-.193l-.035-10.275c-.75.336-1.58.521-2.455.521-3.324 0-6.018-2.693-6.018-6.018s2.693-6.018 6.018-6.018 6.018 2.693 6.018 6.018v15.012h9.939l.008-15.012zM168.412 77.576h9.93v26.006c0 3.191 2.482 5.082 5.91 5.318v8.629c-5.318.119-15.84-3.545-15.84-13.711V77.576z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
public/providers/nvidia.svg Executable file
View File

@ -0,0 +1 @@
<svg viewBox="0 0 271.7 179.7" xmlns="http://www.w3.org/2000/svg" width="2500" height="1653"><path d="M101.3 53.6V37.4c1.6-.1 3.2-.2 4.8-.2 44.4-1.4 73.5 38.2 73.5 38.2S148.2 119 114.5 119c-4.5 0-8.9-.7-13.1-2.1V67.7c17.3 2.1 20.8 9.7 31.1 27l23.1-19.4s-16.9-22.1-45.3-22.1c-3-.1-6 .1-9 .4m0-53.6v24.2l4.8-.3c61.7-2.1 102 50.6 102 50.6s-46.2 56.2-94.3 56.2c-4.2 0-8.3-.4-12.4-1.1v15c3.4.4 6.9.7 10.3.7 44.8 0 77.2-22.9 108.6-49.9 5.2 4.2 26.5 14.3 30.9 18.7-29.8 25-99.3 45.1-138.7 45.1-3.8 0-7.4-.2-11-.6v21.1h170.2V0H101.3zm0 116.9v12.8c-41.4-7.4-52.9-50.5-52.9-50.5s19.9-22 52.9-25.6v14h-.1c-17.3-2.1-30.9 14.1-30.9 14.1s7.7 27.3 31 35.2M27.8 77.4s24.5-36.2 73.6-40V24.2C47 28.6 0 74.6 0 74.6s26.6 77 101.3 84v-14c-54.8-6.8-73.5-67.2-73.5-67.2z" fill="#76b900"/></svg>

After

Width:  |  Height:  |  Size: 771 B

1
public/providers/openai.svg Executable file
View File

@ -0,0 +1 @@
<svg height="2500" viewBox="-1 -.1 949.1 959.8" width="2474" xmlns="http://www.w3.org/2000/svg"><path d="m925.8 456.3c10.4 23.2 17 48 19.7 73.3 2.6 25.3 1.3 50.9-4.1 75.8-5.3 24.9-14.5 48.8-27.3 70.8-8.4 14.7-18.3 28.5-29.7 41.2-11.3 12.6-23.9 24-37.6 34-13.8 10-28.5 18.4-44.1 25.3-15.5 6.8-31.7 12-48.3 15.4-7.8 24.2-19.4 47.1-34.4 67.7-14.9 20.6-33 38.7-53.6 53.6-20.6 15-43.4 26.6-67.6 34.4-24.2 7.9-49.5 11.8-75 11.8-16.9.1-33.9-1.7-50.5-5.1-16.5-3.5-32.7-8.8-48.2-15.7s-30.2-15.5-43.9-25.5c-13.6-10-26.2-21.5-37.4-34.2-25 5.4-50.6 6.7-75.9 4.1-25.3-2.7-50.1-9.3-73.4-19.7-23.2-10.3-44.7-24.3-63.6-41.4s-35-37.1-47.7-59.1c-8.5-14.7-15.5-30.2-20.8-46.3s-8.8-32.7-10.6-49.6c-1.8-16.8-1.7-33.8.1-50.7 1.8-16.8 5.5-33.4 10.8-49.5-17-18.9-31-40.4-41.4-63.6-10.3-23.3-17-48-19.6-73.3-2.7-25.3-1.3-50.9 4-75.8s14.5-48.8 27.3-70.8c8.4-14.7 18.3-28.6 29.6-41.2s24-24 37.7-34 28.5-18.5 44-25.3c15.6-6.9 31.8-12 48.4-15.4 7.8-24.3 19.4-47.1 34.3-67.7 15-20.6 33.1-38.7 53.7-53.7 20.6-14.9 43.4-26.5 67.6-34.4 24.2-7.8 49.5-11.8 75-11.7 16.9-.1 33.9 1.6 50.5 5.1s32.8 8.7 48.3 15.6c15.5 7 30.2 15.5 43.9 25.5 13.7 10.1 26.3 21.5 37.5 34.2 24.9-5.3 50.5-6.6 75.8-4s50 9.3 73.3 19.6c23.2 10.4 44.7 24.3 63.6 41.4 18.9 17 35 36.9 47.7 59 8.5 14.6 15.5 30.1 20.8 46.3 5.3 16.1 8.9 32.7 10.6 49.6 1.8 16.9 1.8 33.9-.1 50.8-1.8 16.9-5.5 33.5-10.8 49.6 17.1 18.9 31 40.3 41.4 63.6zm-333.2 426.9c21.8-9 41.6-22.3 58.3-39s30-36.5 39-58.4c9-21.8 13.7-45.2 13.7-68.8v-223q-.1-.3-.2-.7-.1-.3-.3-.6-.2-.3-.5-.5-.3-.3-.6-.4l-80.7-46.6v269.4c0 2.7-.4 5.5-1.1 8.1-.7 2.7-1.7 5.2-3.1 7.6s-3 4.6-5 6.5a32.1 32.1 0 0 1 -6.5 5l-191.1 110.3c-1.6 1-4.3 2.4-5.7 3.2 7.9 6.7 16.5 12.6 25.5 17.8 9.1 5.2 18.5 9.6 28.3 13.2 9.8 3.5 19.9 6.2 30.1 8 10.3 1.8 20.7 2.7 31.1 2.7 23.6 0 47-4.7 68.8-13.8zm-455.1-151.4c11.9 20.5 27.6 38.3 46.3 52.7 18.8 14.4 40.1 24.9 62.9 31s46.6 7.7 70 4.6 45.9-10.7 66.4-22.5l193.2-111.5.5-.5q.2-.2.3-.6.2-.3.3-.6v-94l-233.2 134.9c-2.4 1.4-4.9 2.4-7.5 3.2-2.7.7-5.4 1-8.2 1-2.7 0-5.4-.3-8.1-1-2.6-.8-5.2-1.8-7.6-3.2l-191.1-110.4c-1.7-1-4.2-2.5-5.6-3.4-1.8 10.3-2.7 20.7-2.7 31.1s1 20.8 2.8 31.1c1.8 10.2 4.6 20.3 8.1 30.1 3.6 9.8 8 19.2 13.2 28.2zm-50.2-417c-11.8 20.5-19.4 43.1-22.5 66.5s-1.5 47.1 4.6 70c6.1 22.8 16.6 44.1 31 62.9 14.4 18.7 32.3 34.4 52.7 46.2l193.1 111.6q.3.1.7.2h.7q.4 0 .7-.2.3-.1.6-.3l81-46.8-233.2-134.6c-2.3-1.4-4.5-3.1-6.5-5a32.1 32.1 0 0 1 -5-6.5c-1.3-2.4-2.4-4.9-3.1-7.6-.7-2.6-1.1-5.3-1-8.1v-227.1c-9.8 3.6-19.3 8-28.3 13.2-9 5.3-17.5 11.3-25.5 18-7.9 6.7-15.3 14.1-22 22.1-6.7 7.9-12.6 16.5-17.8 25.5zm663.3 154.4c2.4 1.4 4.6 3 6.6 5 1.9 1.9 3.6 4.1 5 6.5 1.3 2.4 2.4 5 3.1 7.6.6 2.7 1 5.4.9 8.2v227.1c32.1-11.8 60.1-32.5 80.8-59.7 20.8-27.2 33.3-59.7 36.2-93.7s-3.9-68.2-19.7-98.5-39.9-55.5-69.5-72.5l-193.1-111.6q-.3-.1-.7-.2h-.7q-.3.1-.7.2-.3.1-.6.3l-80.6 46.6 233.2 134.7zm80.5-121h-.1v.1zm-.1-.1c5.8-33.6 1.9-68.2-11.3-99.7-13.1-31.5-35-58.6-63-78.2-28-19.5-61-30.7-95.1-32.2-34.2-1.4-68 6.9-97.6 23.9l-193.1 111.5q-.3.2-.5.5l-.4.6q-.1.3-.2.7-.1.3-.1.7v93.2l233.2-134.7c2.4-1.4 5-2.4 7.6-3.2 2.7-.7 5.4-1 8.1-1 2.8 0 5.5.3 8.2 1 2.6.8 5.1 1.8 7.5 3.2l191.1 110.4c1.7 1 4.2 2.4 5.6 3.3zm-505.3-103.2c0-2.7.4-5.4 1.1-8.1.7-2.6 1.7-5.2 3.1-7.6 1.4-2.3 3-4.5 5-6.5 1.9-1.9 4.1-3.6 6.5-4.9l191.1-110.3c1.8-1.1 4.3-2.5 5.7-3.2-26.2-21.9-58.2-35.9-92.1-40.2-33.9-4.4-68.3 1-99.2 15.5-31 14.5-57.2 37.6-75.5 66.4-18.3 28.9-28 62.3-28 96.5v223q.1.4.2.7.1.3.3.6.2.3.5.6.2.2.6.4l80.7 46.6zm43.8 294.7 103.9 60 103.9-60v-119.9l-103.8-60-103.9 60z"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="1.5 0 21 24" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M19.785 0v7.272H22.5V17.62h-2.935V24l-7.037-6.194v6.145h-1.091v-6.152L4.392 24v-6.465H1.5V7.188h2.884V0l7.053 6.494V.19h1.09v6.49L19.786 0zm-7.257 9.044v7.319l5.946 5.234V14.44l-5.946-5.397zm-1.099-.08l-5.946 5.398v7.235l5.946-5.234V8.965zm8.136 7.58h1.844V8.349H13.46l6.105 5.54v2.655zm-8.982-8.28H2.59v8.195h1.8v-2.576l6.192-5.62zM5.475 2.476v4.71h5.115l-5.115-4.71zm13.219 0l-5.115 4.71h5.115v-4.71z"/></svg>

After

Width:  |  Height:  |  Size: 579 B

1
public/providers/xai.svg Executable file
View File

@ -0,0 +1 @@
<svg version="1" xmlns="http://www.w3.org/2000/svg" width="2222" height="2500" viewBox="2.983000000000004 8.629 908.019 1007.381"><path d="M827.76 200.32L745.02 318.5l-.01 348.75L745 1016h166.002l-.251-466.93-.251-466.93-82.74 118.18M3.167 365.816c.183.449 102.641 146.926 227.684 325.505l227.35 324.689 100.486-.255 100.485-.255-227.675-325.25L203.822 365H103.328c-55.272 0-100.345.367-100.161.816M801 8.787l-93.5.286-174 248.569c-95.7 136.713-174.388 249.381-174.863 250.374-.686 1.436 9.177 16.156 48.345 72.144 27.065 38.687 49.728 70.88 50.363 71.54 1.033 1.073 37.65-50.44 128.994-181.471 2.112-3.029 54.285-77.557 115.941-165.618C763.937 216.55 815.619 142.7 817.13 140.5c1.51-2.2 22.768-32.575 47.238-67.5L908.86 9.5l-7.18-.5c-3.949-.275-49.255-.371-100.68-.213M103.273 872.277L3.047 1015.5l100.726.21 100.727.21 45.206-64.71c24.864-35.591 47.462-67.909 50.219-71.819l5.013-7.109-49.972-71.391c-27.484-39.265-50.308-71.491-50.719-71.614-.411-.122-45.849 64.228-100.974 143" fill="#000000FF" stroke="#000000FF"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

747
src/app/actions/ai-config.ts Executable file
View File

@ -0,0 +1,747 @@
"use server"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq, desc, and, gte } from "drizzle-orm"
import { revalidatePath } from "next/cache"
import { getDb } from "@/db"
import {
agentConfig,
agentUsage,
userModelPreference,
} from "@/db/schema-ai-config"
import { getCurrentUser } from "@/lib/auth"
import { can } from "@/lib/permissions"
// --- types ---
interface ModelInfo {
readonly id: string
readonly name: string
readonly provider: string
readonly contextLength: number
readonly promptCost: string
readonly completionCost: string
}
interface ProviderGroup {
readonly provider: string
readonly models: ReadonlyArray<ModelInfo>
}
interface UsageMetrics {
readonly totalRequests: number
readonly totalTokens: number
readonly totalCost: string
readonly dailyBreakdown: ReadonlyArray<{
date: string
tokens: number
cost: string
requests: number
}>
readonly modelBreakdown: ReadonlyArray<{
modelId: string
tokens: number
cost: string
requests: number
}>
}
// --- module-level cache for model list ---
let cachedModels: ReadonlyArray<ProviderGroup> | null =
null
let cacheTimestamp = 0
const CACHE_TTL_MS = 5 * 60 * 1000
// --- actions ---
export async function getActiveModel(): Promise<{
success: true
data: {
modelId: string
modelName: string
provider: string
promptCost: string
completionCost: string
contextLength: number
maxCostPerMillion: string | null
allowUserSelection: boolean
isAdmin: boolean
} | null
} | {
success: false
error: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const isAdmin = can(user, "agent", "update")
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
return {
success: true,
data: config
? {
modelId: config.modelId,
modelName: config.modelName,
provider: config.provider,
promptCost: config.promptCost,
completionCost: config.completionCost,
contextLength: config.contextLength,
maxCostPerMillion:
config.maxCostPerMillion ?? null,
allowUserSelection:
config.allowUserSelection === 1,
isAdmin,
}
: null,
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get active model",
}
}
}
export async function setActiveModel(
modelId: string,
modelName: string,
provider: string,
promptCost: string,
completionCost: string,
contextLength: number
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const existing = await db
.select({ id: agentConfig.id })
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
if (existing) {
await db
.update(agentConfig)
.set({
modelId,
modelName,
provider,
promptCost,
completionCost,
contextLength,
updatedBy: user.id,
updatedAt: now,
})
.where(eq(agentConfig.id, "global"))
.run()
} else {
await db
.insert(agentConfig)
.values({
id: "global",
modelId,
modelName,
provider,
promptCost,
completionCost,
contextLength,
updatedBy: user.id,
updatedAt: now,
})
.run()
}
revalidatePath("/")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to set model",
}
}
}
export async function getModelList(): Promise<{
success: true
data: ReadonlyArray<ProviderGroup>
} | {
success: false
error: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (
cachedModels &&
Date.now() - cacheTimestamp < CACHE_TTL_MS
) {
return { success: true, data: cachedModels }
}
const { env } = await getCloudflareContext()
const apiKey = (
env as unknown as Record<string, string>
).OPENROUTER_API_KEY
if (!apiKey) {
return {
success: false,
error: "OPENROUTER_API_KEY not configured",
}
}
const res = await fetch(
"https://openrouter.ai/api/v1/models",
{
headers: { Authorization: `Bearer ${apiKey}` },
}
)
if (!res.ok) {
if (cachedModels) {
return { success: true, data: cachedModels }
}
return {
success: false,
error: `OpenRouter API error: ${res.status}`,
}
}
const json = (await res.json()) as {
data: ReadonlyArray<{
id: string
name: string
context_length: number
pricing: {
prompt: string
completion: string
}
}>
}
const groupMap = new Map<string, ModelInfo[]>()
for (const m of json.data) {
const slashIdx = m.id.indexOf("/")
const providerSlug =
slashIdx > 0 ? m.id.slice(0, slashIdx) : "other"
const providerName = formatProvider(providerSlug)
const info: ModelInfo = {
id: m.id,
name: m.name,
provider: providerName,
contextLength: m.context_length,
promptCost: m.pricing.prompt,
completionCost: m.pricing.completion,
}
const existing = groupMap.get(providerName)
if (existing) {
existing.push(info)
} else {
groupMap.set(providerName, [info])
}
}
const groups: ReadonlyArray<ProviderGroup> = Array.from(
groupMap.entries()
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([provider, models]) => ({
provider,
models: models.sort((a, b) =>
a.name.localeCompare(b.name)
),
}))
cachedModels = groups
cacheTimestamp = Date.now()
return { success: true, data: groups }
} catch (err) {
if (cachedModels) {
return { success: true, data: cachedModels }
}
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to fetch models",
}
}
}
export async function getUsageMetrics(
days = 30
): Promise<{
success: true
data: UsageMetrics
} | {
success: false
error: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - days)
const cutoffStr = cutoff.toISOString()
const rows = await db
.select()
.from(agentUsage)
.where(gte(agentUsage.createdAt, cutoffStr))
.orderBy(desc(agentUsage.createdAt))
.all()
let totalTokens = 0
let totalCost = 0
const dailyMap = new Map<
string,
{ tokens: number; cost: number; requests: number }
>()
const modelMap = new Map<
string,
{ tokens: number; cost: number; requests: number }
>()
for (const row of rows) {
totalTokens += row.totalTokens
totalCost += parseFloat(row.estimatedCost)
const date = row.createdAt.slice(0, 10)
const daily = dailyMap.get(date) ?? {
tokens: 0,
cost: 0,
requests: 0,
}
daily.tokens += row.totalTokens
daily.cost += parseFloat(row.estimatedCost)
daily.requests += 1
dailyMap.set(date, daily)
const model = modelMap.get(row.modelId) ?? {
tokens: 0,
cost: 0,
requests: 0,
}
model.tokens += row.totalTokens
model.cost += parseFloat(row.estimatedCost)
model.requests += 1
modelMap.set(row.modelId, model)
}
return {
success: true,
data: {
totalRequests: rows.length,
totalTokens,
totalCost: totalCost.toFixed(4),
dailyBreakdown: Array.from(dailyMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, d]) => ({
date,
tokens: d.tokens,
cost: d.cost.toFixed(4),
requests: d.requests,
})),
modelBreakdown: Array.from(modelMap.entries())
.sort(
([, a], [, b]) => b.requests - a.requests
)
.map(([modelId, d]) => ({
modelId,
tokens: d.tokens,
cost: d.cost.toFixed(4),
requests: d.requests,
})),
},
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get metrics",
}
}
}
export async function getConversationUsage(
conversationId: string
): Promise<{
success: true
data: ReadonlyArray<{
modelId: string
promptTokens: number
completionTokens: number
totalTokens: number
estimatedCost: string
createdAt: string
}>
} | {
success: false
error: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const isAdmin = can(user, "agent", "update")
const rows = await db
.select()
.from(agentUsage)
.where(
isAdmin
? eq(agentUsage.conversationId, conversationId)
: and(
eq(
agentUsage.conversationId,
conversationId
),
eq(agentUsage.userId, user.id)
)
)
.orderBy(desc(agentUsage.createdAt))
.all()
return {
success: true,
data: rows.map((r) => ({
modelId: r.modelId,
promptTokens: r.promptTokens,
completionTokens: r.completionTokens,
totalTokens: r.totalTokens,
estimatedCost: r.estimatedCost,
createdAt: r.createdAt,
})),
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get usage",
}
}
}
// --- model policy ---
export async function updateModelPolicy(
maxCostPerMillion: string | null,
allowUserSelection: boolean
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
if (!can(user, "agent", "update")) {
return {
success: false,
error: "Permission denied",
}
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const now = new Date().toISOString()
const existing = await db
.select({ id: agentConfig.id })
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
if (existing) {
await db
.update(agentConfig)
.set({
maxCostPerMillion,
allowUserSelection: allowUserSelection
? 1
: 0,
updatedBy: user.id,
updatedAt: now,
})
.where(eq(agentConfig.id, "global"))
.run()
} else {
const {
DEFAULT_MODEL_ID,
} = await import("@/lib/agent/provider")
await db
.insert(agentConfig)
.values({
id: "global",
modelId: DEFAULT_MODEL_ID,
modelName: "Default",
provider: "default",
promptCost: "0",
completionCost: "0",
contextLength: 0,
maxCostPerMillion,
allowUserSelection: allowUserSelection
? 1
: 0,
updatedBy: user.id,
updatedAt: now,
})
.run()
}
revalidatePath("/")
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to update policy",
}
}
}
// --- user model preference ---
export async function getUserModelPreference(): Promise<{
success: true
data: {
modelId: string
promptCost: string
completionCost: string
} | null
} | {
success: false
error: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const pref = await db
.select()
.from(userModelPreference)
.where(eq(userModelPreference.userId, user.id))
.get()
return {
success: true,
data: pref
? {
modelId: pref.modelId,
promptCost: pref.promptCost,
completionCost: pref.completionCost,
}
: null,
}
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to get preference",
}
}
}
export async function setUserModelPreference(
modelId: string,
promptCost: string,
completionCost: string
): Promise<{ success: boolean; error?: string }> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
const config = await db
.select()
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
const isAdmin = can(user, "agent", "update")
if (
!isAdmin &&
config &&
config.allowUserSelection !== 1
) {
return {
success: false,
error: "User model selection is disabled",
}
}
if (config?.maxCostPerMillion) {
const ceiling = parseFloat(
config.maxCostPerMillion
)
const outputPerMillion =
parseFloat(completionCost) * 1_000_000
if (outputPerMillion > ceiling) {
return {
success: false,
error: `Model exceeds cost ceiling of $${config.maxCostPerMillion}/M`,
}
}
}
const now = new Date().toISOString()
await db
.insert(userModelPreference)
.values({
userId: user.id,
modelId,
promptCost,
completionCost,
updatedAt: now,
})
.onConflictDoUpdate({
target: userModelPreference.userId,
set: {
modelId,
promptCost,
completionCost,
updatedAt: now,
},
})
.run()
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to save preference",
}
}
}
export async function clearUserModelPreference(): Promise<{
success: boolean
error?: string
}> {
try {
const user = await getCurrentUser()
if (!user) {
return { success: false, error: "Unauthorized" }
}
const { env } = await getCloudflareContext()
const db = getDb(env.DB)
await db
.delete(userModelPreference)
.where(eq(userModelPreference.userId, user.id))
.run()
return { success: true }
} catch (err) {
return {
success: false,
error:
err instanceof Error
? err.message
: "Failed to clear preference",
}
}
}
// --- helpers ---
function formatProvider(slug: string): string {
const map: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
meta: "Meta",
mistralai: "Mistral",
qwen: "Alibaba (Qwen)",
deepseek: "DeepSeek",
cohere: "Cohere",
"x-ai": "xAI",
nvidia: "NVIDIA",
microsoft: "Microsoft",
amazon: "Amazon",
perplexity: "Perplexity",
moonshotai: "Moonshot",
}
return (
map[slug] ??
slug.charAt(0).toUpperCase() + slug.slice(1)
)
}

View File

@ -5,12 +5,16 @@ import {
type UIMessage,
} from "ai"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { getAgentModel } from "@/lib/agent/provider"
import {
resolveModelForUser,
createModelFromId,
} from "@/lib/agent/provider"
import { agentTools } from "@/lib/agent/tools"
import { githubTools } from "@/lib/agent/github-tools"
import { buildSystemPrompt } from "@/lib/agent/system-prompt"
import { loadMemoriesForPrompt } from "@/lib/agent/memory"
import { getRegistry } from "@/lib/agent/plugins/registry"
import { saveStreamUsage } from "@/lib/agent/usage"
import { getCurrentUser } from "@/lib/auth"
import { getDb } from "@/db"
@ -20,7 +24,7 @@ export async function POST(req: Request): Promise<Response> {
return new Response("Unauthorized", { status: 401 })
}
const { env } = await getCloudflareContext()
const { env, ctx } = await getCloudflareContext()
const db = getDb(env.DB)
const envRecord = env as unknown as Record<string, string>
@ -39,8 +43,18 @@ export async function POST(req: Request): Promise<Response> {
req.headers.get("x-current-page") ?? undefined
const timezone =
req.headers.get("x-timezone") ?? undefined
const conversationId =
req.headers.get("x-conversation-id") ||
crypto.randomUUID()
const model = await getAgentModel()
const modelId = await resolveModelForUser(
db,
user.id
)
const model = createModelFromId(
envRecord.OPENROUTER_API_KEY,
modelId
)
const result = streamText({
model,
@ -58,5 +72,15 @@ export async function POST(req: Request): Promise<Response> {
stopWhen: stepCountIs(10),
})
ctx.waitUntil(
saveStreamUsage(
db,
conversationId,
user.id,
modelId,
result
)
)
return result.toUIMessageStreamResponse()
}

View File

@ -48,7 +48,7 @@ interface ChatStateValue {
stop: () => void
readonly status: string
readonly isGenerating: boolean
readonly conversationId: string | null
readonly conversationId: string
newChat: () => void
readonly pathname: string
}
@ -158,7 +158,14 @@ export function ChatProvider({
}) {
const [isOpen, setIsOpen] = React.useState(false)
const [conversationId, setConversationId] =
React.useState<string | null>(null)
React.useState("")
// generate initial ID client-side only to avoid hydration mismatch
React.useEffect(() => {
setConversationId((prev) =>
prev === "" ? crypto.randomUUID() : prev
)
}, [])
const [resumeLoaded, setResumeLoaded] =
React.useState(false)
const [dataContext, setDataContext] = React.useState<
@ -169,13 +176,11 @@ export function ChatProvider({
const pathname = usePathname()
const chat = useCompassChat({
conversationId,
openPanel: () => setIsOpen(true),
onFinish: async ({ messages: finalMessages }) => {
if (finalMessages.length === 0) return
const id = conversationId ?? crypto.randomUUID()
if (!conversationId) setConversationId(id)
const serialized = finalMessages.map((m) => ({
id: m.id,
role: m.role,
@ -189,7 +194,7 @@ export function ChatProvider({
createdAt: new Date().toISOString(),
}))
await saveConversation(id, serialized)
await saveConversation(conversationId, serialized)
},
})
@ -339,7 +344,7 @@ export function ChatProvider({
const newChat = React.useCallback(() => {
chat.setMessages([])
setConversationId(null)
setConversationId(crypto.randomUUID())
setResumeLoaded(true)
clearRender()
renderDispatchedRef.current.clear()

View File

@ -2,7 +2,6 @@
import { useState, useCallback, useRef, useEffect } from "react"
import {
SendHorizonal,
CopyIcon,
ThumbsUpIcon,
ThumbsDownIcon,
@ -59,6 +58,7 @@ import { useAudioRecorder } from "@/hooks/use-audio-recorder"
import type { AudioRecorder } from "@/hooks/use-audio-recorder"
import { AudioWaveform } from "@/components/ai/audio-waveform"
import { useChatState } from "./chat-provider"
import { ModelDropdown } from "./model-dropdown"
import { getRepoStats } from "@/app/actions/github"
type RepoStats = {
@ -373,6 +373,7 @@ function ChatInput({
<SquarePenIcon className="size-4" />
</PromptInputButton>
)}
<ModelDropdown />
</PromptInputTools>
<div className="flex items-center gap-1">
<PromptInputButton
@ -431,7 +432,6 @@ export function ChatView({ variant }: ChatViewProps) {
const recorder = useAudioRecorder(handleTranscription)
const [isActive, setIsActive] = useState(false)
const [idleInput, setIdleInput] = useState("")
const [copiedId, setCopiedId] = useState<string | null>(
null
)
@ -439,8 +439,6 @@ export function ChatView({ variant }: ChatViewProps) {
// typewriter animation state (page variant only)
const [animatedPlaceholder, setAnimatedPlaceholder] =
useState("")
const [animFading, setAnimFading] = useState(false)
const [isIdleFocused, setIsIdleFocused] = useState(false)
const animTimerRef =
useRef<ReturnType<typeof setTimeout>>(undefined)
@ -454,14 +452,8 @@ export function ChatView({ variant }: ChatViewProps) {
// typewriter animation for idle input (page variant)
useEffect(() => {
if (
!isPage ||
isIdleFocused ||
idleInput ||
isActive
) {
if (!isPage || isActive) {
setAnimatedPlaceholder("")
setAnimFading(false)
return
}
@ -486,7 +478,6 @@ export function ChatView({ variant }: ChatViewProps) {
}
} else if (phase === "pause") {
phase = "fading"
setAnimFading(true)
animTimerRef.current = setTimeout(tick, 400)
} else {
msgIdx =
@ -495,7 +486,6 @@ export function ChatView({ variant }: ChatViewProps) {
setAnimatedPlaceholder(
ANIMATED_PLACEHOLDERS[msgIdx].slice(0, 1)
)
setAnimFading(false)
phase = "typing"
animTimerRef.current = setTimeout(tick, 50)
}
@ -507,7 +497,7 @@ export function ChatView({ variant }: ChatViewProps) {
if (animTimerRef.current)
clearTimeout(animTimerRef.current)
}
}, [isPage, isIdleFocused, idleInput, isActive])
}, [isPage, isActive])
// escape to return to idle when no messages (page)
useEffect(() => {
@ -535,19 +525,6 @@ export function ChatView({ variant }: ChatViewProps) {
[]
)
const handleIdleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault()
const value = idleInput.trim()
setIsActive(true)
if (value) {
chat.sendMessage({ text: value })
setIdleInput("")
}
},
[idleInput, chat.sendMessage]
)
const handleSuggestion = useCallback(
(text: string) => {
if (isPage) setIsActive(true)
@ -608,36 +585,20 @@ export function ChatView({ variant }: ChatViewProps) {
incomplete or change without notice.
</p>
</div>
<form onSubmit={handleIdleSubmit}>
<label className="group flex w-full items-center gap-2 rounded-full border bg-background px-5 py-3 text-sm shadow-sm transition-colors hover:border-primary/30 hover:bg-muted/30 cursor-text">
<input
value={idleInput}
onChange={(e) =>
setIdleInput(e.target.value)
}
onFocus={() => setIsIdleFocused(true)}
onBlur={() => setIsIdleFocused(false)}
placeholder={
animatedPlaceholder ||
"Ask anything..."
}
className={cn(
"flex-1 bg-transparent text-foreground outline-none",
"placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-300",
animFading
? "placeholder:opacity-0"
: "placeholder:opacity-100"
)}
/>
<button
type="submit"
className="shrink-0"
aria-label="Send"
>
<SendHorizonal className="size-4 text-muted-foreground/60 transition-colors group-hover:text-primary" />
</button>
</label>
</form>
<ChatInput
textareaRef={textareaRef}
placeholder={
animatedPlaceholder || "Ask anything..."
}
recorder={recorder}
status={chat.status}
isGenerating={chat.isGenerating}
onSend={(text) => {
setIsActive(true)
chat.sendMessage({ text })
}}
className="rounded-2xl"
/>
{stats && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-xs text-muted-foreground/70">

View File

@ -0,0 +1,569 @@
"use client"
import * as React from "react"
import {
ChevronDown,
Check,
Search,
Loader2,
} from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { ProviderIcon, hasLogo } from "./provider-icon"
import {
getActiveModel,
getModelList,
getUserModelPreference,
setUserModelPreference,
} from "@/app/actions/ai-config"
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
const DEFAULT_MODEL_NAME = "Qwen3 Coder"
const DEFAULT_PROVIDER = "Alibaba (Qwen)"
// --- shared state so all instances stay in sync ---
interface SharedState {
readonly display: {
readonly id: string
readonly name: string
readonly provider: string
}
readonly global: {
readonly id: string
readonly name: string
readonly provider: string
}
readonly allowUserSelection: boolean
readonly isAdmin: boolean
readonly maxCostPerMillion: string | null
readonly configLoaded: boolean
}
let shared: SharedState = {
display: {
id: DEFAULT_MODEL_ID,
name: DEFAULT_MODEL_NAME,
provider: DEFAULT_PROVIDER,
},
global: {
id: DEFAULT_MODEL_ID,
name: DEFAULT_MODEL_NAME,
provider: DEFAULT_PROVIDER,
},
allowUserSelection: true,
isAdmin: false,
maxCostPerMillion: null,
configLoaded: false,
}
const listeners = new Set<() => void>()
function getSnapshot(): SharedState {
return shared
}
function setShared(
next: Partial<SharedState>
): void {
shared = { ...shared, ...next }
for (const l of listeners) l()
}
function subscribe(
listener: () => void
): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
interface ModelInfo {
readonly id: string
readonly name: string
readonly provider: string
readonly contextLength: number
readonly promptCost: string
readonly completionCost: string
}
interface ProviderGroup {
readonly provider: string
readonly models: ReadonlyArray<ModelInfo>
}
function outputCostPerMillion(
completionCost: string
): number {
return parseFloat(completionCost) * 1_000_000
}
function formatOutputCost(
completionCost: string
): string {
const cost = outputCostPerMillion(completionCost)
if (cost === 0) return "free"
if (cost < 0.01) return "<$0.01/M"
return `$${cost.toFixed(2)}/M`
}
export function ModelDropdown(): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const state = React.useSyncExternalStore(
subscribe,
getSnapshot,
getSnapshot
)
const [groups, setGroups] = React.useState<
ReadonlyArray<ProviderGroup>
>([])
const [loading, setLoading] = React.useState(false)
const [search, setSearch] = React.useState("")
const [saving, setSaving] = React.useState<
string | null
>(null)
const [listLoaded, setListLoaded] =
React.useState(false)
const [activeProvider, setActiveProvider] =
React.useState<string | null>(null)
React.useEffect(() => {
if (state.configLoaded) return
setShared({ configLoaded: true })
Promise.all([
getActiveModel(),
getUserModelPreference(),
]).then(([configResult, prefResult]) => {
let gModelId = DEFAULT_MODEL_ID
let gModelName = DEFAULT_MODEL_NAME
let gProvider = DEFAULT_PROVIDER
let canSelect = true
let ceiling: string | null = null
let admin = false
if (configResult.success && configResult.data) {
gModelId = configResult.data.modelId
gModelName = configResult.data.modelName
gProvider = configResult.data.provider
canSelect =
configResult.data.allowUserSelection
ceiling =
configResult.data.maxCostPerMillion
admin = configResult.data.isAdmin
}
const base: Partial<SharedState> = {
global: {
id: gModelId,
name: gModelName,
provider: gProvider,
},
allowUserSelection: canSelect,
isAdmin: admin,
maxCostPerMillion: ceiling,
}
if (
canSelect &&
prefResult.success &&
prefResult.data
) {
const prefValid =
ceiling === null ||
outputCostPerMillion(
prefResult.data.completionCost
) <= parseFloat(ceiling)
if (prefValid) {
const slashIdx =
prefResult.data.modelId.indexOf("/")
setShared({
...base,
display: {
id: prefResult.data.modelId,
name:
slashIdx > 0
? prefResult.data.modelId.slice(
slashIdx + 1
)
: prefResult.data.modelId,
provider: "",
},
})
return
}
}
setShared({
...base,
display: {
id: gModelId,
name: gModelName,
provider: gProvider,
},
})
})
}, [state.configLoaded])
React.useEffect(() => {
if (!open || listLoaded) return
setLoading(true)
getModelList().then((result) => {
if (result.success) {
const sorted = [...result.data]
.sort((a, b) => {
const aHas = hasLogo(a.provider) ? 0 : 1
const bHas = hasLogo(b.provider) ? 0 : 1
if (aHas !== bHas) return aHas - bHas
return a.provider.localeCompare(
b.provider
)
})
.map((g) => ({
...g,
models: [...g.models].sort(
(a, b) =>
outputCostPerMillion(
a.completionCost
) -
outputCostPerMillion(
b.completionCost
)
),
}))
setGroups(sorted)
}
setListLoaded(true)
setLoading(false)
})
}, [open, listLoaded])
// reset provider filter when popover closes
React.useEffect(() => {
if (!open) {
setActiveProvider(null)
setSearch("")
}
}, [open])
const query = search.toLowerCase()
const ceiling = state.maxCostPerMillion
? parseFloat(state.maxCostPerMillion)
: null
const filtered = React.useMemo(() => {
return groups
.map((g) => ({
...g,
models: g.models.filter((m) => {
if (ceiling !== null) {
if (
outputCostPerMillion(
m.completionCost
) > ceiling
)
return false
}
if (
activeProvider &&
g.provider !== activeProvider
) {
return false
}
if (!query) return true
return (
m.name.toLowerCase().includes(query) ||
m.id.toLowerCase().includes(query)
)
}),
}))
.filter((g) => g.models.length > 0)
}, [groups, query, ceiling, activeProvider])
const totalFiltered = React.useMemo(() => {
let count = 0
for (const g of groups) {
for (const m of g.models) {
if (
ceiling === null ||
outputCostPerMillion(m.completionCost) <=
ceiling
) {
count++
}
}
}
return count
}, [groups, ceiling])
// sorted groups for provider sidebar (cost-filtered)
const sortedGroups = React.useMemo(() => {
return groups
.map((g) => ({
...g,
models: g.models.filter((m) => {
if (ceiling === null) return true
return (
outputCostPerMillion(
m.completionCost
) <= ceiling
)
}),
}))
.filter((g) => g.models.length > 0)
}, [groups, ceiling])
const handleSelect = async (
model: ModelInfo
): Promise<void> => {
if (model.id === state.display.id) {
setOpen(false)
return
}
setSaving(model.id)
const result = await setUserModelPreference(
model.id,
model.promptCost,
model.completionCost
)
setSaving(null)
if (result.success) {
setShared({
display: {
id: model.id,
name: model.name,
provider: model.provider,
},
})
toast.success(`Switched to ${model.name}`)
setOpen(false)
} else {
toast.error(result.error ?? "Failed to switch")
}
}
if (!state.allowUserSelection && !state.isAdmin) {
return (
<div className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground">
<ProviderIcon
provider={state.global.provider}
size={14}
/>
<span className="max-w-28 truncate">
{state.global.name}
</span>
</div>
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground",
"hover:bg-muted hover:text-foreground transition-colors",
open && "bg-muted text-foreground"
)}
>
<ProviderIcon
provider={state.display.provider}
size={14}
/>
<span className="max-w-28 truncate">
{state.display.name}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-96 p-0"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<TooltipProvider delayDuration={200}>
{/* search */}
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
value={search}
onChange={(e) =>
setSearch(e.target.value)
}
placeholder="Search models..."
className="h-8 pl-8 text-xs"
/>
</div>
</div>
{/* two-panel layout */}
<div className="flex h-72">
{/* provider sidebar */}
<div className="w-11 shrink-0 overflow-y-auto flex flex-col items-center gap-0.5 py-1 border-r">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(null)
}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-[10px] font-semibold transition-all shrink-0",
activeProvider === null
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted"
)}
>
All
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
All providers
</p>
</TooltipContent>
</Tooltip>
{sortedGroups.map((group) => (
<Tooltip key={group.provider}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(
activeProvider ===
group.provider
? null
: group.provider
)
}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-all shrink-0",
activeProvider ===
group.provider
? "bg-primary/15 scale-110"
: "hover:bg-muted"
)}
>
<ProviderIcon
provider={group.provider}
size={18}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
{group.provider} (
{group.models.length})
</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* model list */}
<div className="flex-1 overflow-y-auto p-1">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
No models found.
</div>
) : (
<div className="space-y-1">
{filtered.map((group) =>
group.models.map((model) => {
const isActive =
model.id === state.display.id
const isSaving =
saving === model.id
return (
<button
key={model.id}
type="button"
disabled={
isSaving ||
saving !== null
}
onClick={() =>
handleSelect(model)
}
className={cn(
"w-full text-left rounded-lg px-2.5 py-2 flex items-center gap-2.5 transition-all",
isActive
? "bg-primary/10 ring-1 ring-primary/30"
: "hover:bg-muted/70",
isSaving && "opacity-50"
)}
>
<ProviderIcon
provider={
model.provider
}
size={20}
className="shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium truncate">
{model.name}
</span>
{isSaving ? (
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
) : isActive ? (
<Check className="h-3 w-3 text-primary shrink-0" />
) : null}
</div>
<Badge
variant="secondary"
className="text-[10px] px-1 py-0 h-3.5 mt-0.5 font-normal"
>
{formatOutputCost(
model.completionCost
)}
</Badge>
</div>
</button>
)
})
)}
</div>
)}
</div>
</div>
{/* budget footer */}
{ceiling !== null && listLoaded && (
<div className="border-t px-3 py-1.5">
<p className="text-[10px] text-muted-foreground">
{totalFiltered} models within $
{state.maxCostPerMillion}/M budget
</p>
</div>
)}
</TooltipProvider>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,78 @@
"use client"
import { cn } from "@/lib/utils"
// provider logo files in /public/providers/
export const PROVIDER_LOGO: Record<string, string> = {
Anthropic: "anthropic",
OpenAI: "openai",
Google: "google",
Meta: "meta",
Mistral: "mistral",
DeepSeek: "deepseek",
xAI: "xai",
NVIDIA: "nvidia",
Microsoft: "microsoft",
Amazon: "amazon",
Perplexity: "perplexity",
}
const PROVIDER_ABBR: Record<string, string> = {
"Alibaba (Qwen)": "Qw",
Cohere: "Co",
Moonshot: "Ms",
}
function getProviderAbbr(name: string): string {
return (
PROVIDER_ABBR[name] ??
name.slice(0, 2).toUpperCase()
)
}
export function hasLogo(provider: string): boolean {
return provider in PROVIDER_LOGO
}
export function ProviderIcon({
provider,
size = 24,
className,
}: {
readonly provider: string
readonly size?: number
readonly className?: string
}): React.JSX.Element {
const logo = PROVIDER_LOGO[provider]
if (logo) {
return (
<img
src={`/providers/${logo}.svg`}
alt={provider}
width={size}
height={size}
className={cn(
"object-contain",
className
)}
/>
)
}
return (
<span
className={cn(
"flex items-center justify-center rounded-full bg-muted/80 text-muted-foreground font-medium tracking-tight",
className
)}
style={{
width: size,
height: size,
fontSize: Math.max(9, size * 0.35),
}}
>
{getProviderAbbr(provider)}
</span>
)
}

View File

@ -27,6 +27,7 @@ import { NetSuiteConnectionStatus } from "@/components/netsuite/connection-statu
import { SyncControls } from "@/components/netsuite/sync-controls"
import { MemoriesTable } from "@/components/agent/memories-table"
import { SkillsTab } from "@/components/settings/skills-tab"
import { AIModelTab } from "@/components/settings/ai-model-tab"
export function SettingsModal({
open,
@ -156,6 +157,8 @@ export function SettingsModal({
const slabMemoryPage = <MemoriesTable />
const aiModelPage = <AIModelTab />
const skillsPage = <SkillsTab />
return (
@ -164,10 +167,10 @@ export function SettingsModal({
onOpenChange={onOpenChange}
title="Settings"
description="Manage your app preferences."
className="sm:max-w-xl"
className="sm:max-w-2xl"
>
<ResponsiveDialogBody
pages={[generalPage, notificationsPage, appearancePage, integrationsPage, slabMemoryPage, skillsPage]}
pages={[generalPage, notificationsPage, appearancePage, integrationsPage, aiModelPage, slabMemoryPage, skillsPage]}
>
<Tabs defaultValue="general" className="w-full">
<TabsList className="w-full inline-flex justify-start overflow-x-auto">
@ -183,6 +186,9 @@ export function SettingsModal({
<TabsTrigger value="integrations" className="text-xs sm:text-sm shrink-0">
Integrations
</TabsTrigger>
<TabsTrigger value="ai-model" className="text-xs sm:text-sm shrink-0">
AI Model
</TabsTrigger>
<TabsTrigger value="slab-memory" className="text-xs sm:text-sm shrink-0">
Slab Memory
</TabsTrigger>
@ -307,6 +313,13 @@ export function SettingsModal({
<SyncControls />
</TabsContent>
<TabsContent
value="ai-model"
className="space-y-3 pt-3"
>
<AIModelTab />
</TabsContent>
<TabsContent
value="slab-memory"
className="space-y-3 pt-3"

View File

@ -0,0 +1,782 @@
"use client"
import * as React from "react"
import {
Check,
Loader2,
Search,
} from "lucide-react"
import {
Bar,
BarChart,
CartesianGrid,
XAxis,
YAxis,
} from "recharts"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart"
import {
getActiveModel,
setActiveModel,
getModelList,
getUsageMetrics,
updateModelPolicy,
} from "@/app/actions/ai-config"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import {
ProviderIcon,
hasLogo,
} from "@/components/agent/provider-icon"
const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
interface ModelInfo {
readonly id: string
readonly name: string
readonly provider: string
readonly contextLength: number
readonly promptCost: string
readonly completionCost: string
}
interface ProviderGroup {
readonly provider: string
readonly models: ReadonlyArray<ModelInfo>
}
interface ActiveConfig {
readonly modelId: string
readonly modelName: string
readonly provider: string
readonly promptCost: string
readonly completionCost: string
readonly contextLength: number
readonly maxCostPerMillion: string | null
readonly allowUserSelection: boolean
readonly isAdmin: boolean
}
interface UsageMetrics {
readonly totalRequests: number
readonly totalTokens: number
readonly totalCost: string
readonly dailyBreakdown: ReadonlyArray<{
date: string
tokens: number
cost: string
requests: number
}>
readonly modelBreakdown: ReadonlyArray<{
modelId: string
tokens: number
cost: string
requests: number
}>
}
function formatCost(costPerToken: string): string {
const perMillion =
parseFloat(costPerToken) * 1_000_000
if (perMillion === 0) return "free"
if (perMillion < 0.01) return "<$0.01/M"
return `$${perMillion.toFixed(2)}/M`
}
function formatOutputCost(
completionCost: string
): string {
const cost =
parseFloat(completionCost) * 1_000_000
if (cost === 0) return "free"
if (cost < 0.01) return "<$0.01/M"
return `$${cost.toFixed(2)}/M`
}
function formatContext(length: number): string {
if (length >= 1_000_000) {
return `${(length / 1_000_000).toFixed(0)}M`
}
return `${(length / 1000).toFixed(0)}k`
}
function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) {
return `${(tokens / 1_000_000).toFixed(1)}M`
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`
}
return String(tokens)
}
function outputCostPerMillion(
completionCost: string
): number {
return parseFloat(completionCost) * 1_000_000
}
// --- two-panel model picker ---
function ModelPicker({
groups,
activeConfig,
onSaved,
maxCostPerMillion,
}: {
readonly groups: ReadonlyArray<ProviderGroup>
readonly activeConfig: ActiveConfig | null
readonly onSaved: () => void
readonly maxCostPerMillion: number | null
}) {
const [search, setSearch] = React.useState("")
const [activeProvider, setActiveProvider] =
React.useState<string | null>(null)
const [selected, setSelected] =
React.useState<ModelInfo | null>(null)
const [saving, setSaving] = React.useState(false)
const currentId =
activeConfig?.modelId ?? DEFAULT_MODEL_ID
const query = search.toLowerCase()
// sort: providers with logos first, then alphabetical
const sortedGroups = React.useMemo(() => {
return [...groups].sort((a, b) => {
const aHas = hasLogo(a.provider) ? 0 : 1
const bHas = hasLogo(b.provider) ? 0 : 1
if (aHas !== bHas) return aHas - bHas
return a.provider.localeCompare(b.provider)
})
}, [groups])
// filter models by search + active provider + cost ceiling
const filteredGroups = React.useMemo(() => {
return sortedGroups
.map((group) => {
if (
activeProvider &&
group.provider !== activeProvider
) {
return { ...group, models: [] }
}
return {
...group,
models: [...group.models]
.filter((m) => {
if (maxCostPerMillion !== null) {
if (
outputCostPerMillion(
m.completionCost
) > maxCostPerMillion
)
return false
}
if (!query) return true
return (
m.name
.toLowerCase()
.includes(query) ||
m.id.toLowerCase().includes(query)
)
})
.sort(
(a, b) =>
outputCostPerMillion(
a.completionCost
) -
outputCostPerMillion(
b.completionCost
)
),
}
})
.filter((g) => g.models.length > 0)
}, [sortedGroups, activeProvider, query, maxCostPerMillion])
const isDirty =
selected !== null && selected.id !== currentId
const handleSave = async () => {
if (!selected) return
setSaving(true)
const result = await setActiveModel(
selected.id,
selected.name,
selected.provider,
selected.promptCost,
selected.completionCost,
selected.contextLength
)
setSaving(false)
if (result.success) {
toast.success("Model updated")
setSelected(null)
onSaved()
} else {
toast.error(result.error ?? "Failed to save")
}
}
return (
<TooltipProvider delayDuration={200}>
<div className="space-y-3">
{/* search bar */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search models..."
className="h-10 pl-9 text-sm"
/>
</div>
{/* two-panel layout - no outer border */}
<div className="flex gap-2 h-80">
{/* provider sidebar */}
<div className="w-12 shrink-0 overflow-y-auto flex flex-col items-center gap-1 py-0.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(null)
}
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center text-xs font-semibold transition-all shrink-0",
activeProvider === null
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:bg-muted"
)}
>
All
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
All providers
</p>
</TooltipContent>
</Tooltip>
{sortedGroups.map((group) => (
<Tooltip key={group.provider}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() =>
setActiveProvider(
activeProvider ===
group.provider
? null
: group.provider
)
}
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center transition-all shrink-0",
activeProvider ===
group.provider
? "bg-primary/15 scale-110"
: "hover:bg-muted"
)}
>
<ProviderIcon
provider={group.provider}
size={22}
/>
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">
{group.provider} (
{group.models.length})
</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* model list */}
<div className="flex-1 overflow-y-auto pr-1">
{filteredGroups.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
No models found.
</div>
) : (
<div className="space-y-1.5">
{filteredGroups.map((group) =>
group.models.map((model) => {
const isActive =
model.id === currentId
const isSelected =
selected?.id === model.id
return (
<button
key={model.id}
type="button"
onClick={() =>
setSelected(model)
}
className={cn(
"w-full text-left rounded-lg px-3 py-2.5 flex items-center gap-3 transition-all",
isSelected
? "bg-primary/10 ring-1 ring-primary/30"
: "bg-muted/40 hover:bg-muted/70",
isActive &&
!isSelected &&
"bg-primary/5 ring-1 ring-primary/20"
)}
>
<ProviderIcon
provider={model.provider}
size={24}
className="shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium truncate">
{model.name}
</span>
{isActive && (
<Check className="h-3.5 w-3.5 text-primary shrink-0" />
)}
</div>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4 mt-1 font-normal"
>
{formatOutputCost(
model.completionCost
)}
</Badge>
</div>
</button>
)
})
)}
</div>
)}
</div>
</div>
{/* save bar */}
{isDirty && (
<div className="flex items-center justify-between pt-1">
<p className="text-xs text-muted-foreground">
Switch to{" "}
<span className="font-medium text-foreground">
{selected?.name}
</span>
</p>
<Button
size="sm"
className="h-8"
onClick={handleSave}
disabled={saving}
>
{saving && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Save
</Button>
</div>
)}
</div>
</TooltipProvider>
)
}
// --- usage metrics ---
const chartConfig = {
tokens: {
label: "Tokens",
color: "var(--chart-1)",
},
} satisfies ChartConfig
function UsageSection({
metrics,
}: {
readonly metrics: UsageMetrics
}) {
return (
<div className="space-y-3">
<Label className="text-xs">
Usage (last 30 days)
</Label>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md border p-2.5">
<p className="text-muted-foreground text-[10px]">
Requests
</p>
<p className="text-lg font-semibold">
{metrics.totalRequests.toLocaleString()}
</p>
</div>
<div className="rounded-md border p-2.5">
<p className="text-muted-foreground text-[10px]">
Tokens
</p>
<p className="text-lg font-semibold">
{formatTokenCount(metrics.totalTokens)}
</p>
</div>
<div className="rounded-md border p-2.5">
<p className="text-muted-foreground text-[10px]">
Est. Cost
</p>
<p className="text-lg font-semibold">
${metrics.totalCost}
</p>
</div>
</div>
{metrics.dailyBreakdown.length > 0 && (
<div className="rounded-md border p-3">
<p className="text-muted-foreground text-[10px] mb-2">
Daily token usage
</p>
<ChartContainer
config={chartConfig}
className="h-32 w-full"
>
<BarChart
data={[...metrics.dailyBreakdown]}
>
<CartesianGrid
vertical={false}
strokeDasharray="3 3"
/>
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickFormatter={(v: string) =>
v.slice(5)
}
className="text-[10px]"
/>
<YAxis
tickLine={false}
axisLine={false}
tickFormatter={formatTokenCount}
width={40}
className="text-[10px]"
/>
<ChartTooltip
content={<ChartTooltipContent />}
/>
<Bar
dataKey="tokens"
fill="var(--color-tokens)"
radius={[2, 2, 0, 0]}
/>
</BarChart>
</ChartContainer>
</div>
)}
{metrics.modelBreakdown.length > 0 && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-[10px]">
By model
</p>
<div className="space-y-1">
{metrics.modelBreakdown.map((m) => (
<div
key={m.modelId}
className="flex items-center justify-between text-xs rounded-md border px-2.5 py-1.5"
>
<span className="truncate max-w-[60%]">
{m.modelId}
</span>
<span className="text-muted-foreground text-[10px] shrink-0">
{m.requests} req &middot;{" "}
{formatTokenCount(m.tokens)} tok
&middot; ${m.cost}
</span>
</div>
))}
</div>
</div>
)}
</div>
)
}
// --- main tab ---
export function AIModelTab() {
const [loading, setLoading] = React.useState(true)
const [activeConfig, setActiveConfig] =
React.useState<ActiveConfig | null>(null)
const [groups, setGroups] = React.useState<
ReadonlyArray<ProviderGroup>
>([])
const [metrics, setMetrics] =
React.useState<UsageMetrics | null>(null)
const [isAdmin, setIsAdmin] = React.useState(false)
const [modelsError, setModelsError] =
React.useState<string | null>(null)
const [allowUserSelection, setAllowUserSelection] =
React.useState(true)
const [costCeiling, setCostCeiling] =
React.useState<number | null>(null)
const [policySaving, setPolicySaving] =
React.useState(false)
const loadData = React.useCallback(async () => {
setLoading(true)
const configResult = await getActiveModel()
if (configResult.success) {
setActiveConfig(configResult.data)
if (configResult.data) {
setAllowUserSelection(
configResult.data.allowUserSelection
)
setCostCeiling(
configResult.data.maxCostPerMillion
? parseFloat(
configResult.data.maxCostPerMillion
)
: null
)
}
}
const [modelsResult, metricsResult] =
await Promise.all([
getModelList(),
getUsageMetrics(),
])
if (modelsResult.success) {
setGroups(modelsResult.data)
setModelsError(null)
} else {
setModelsError(modelsResult.error)
}
if (metricsResult.success) {
setMetrics(metricsResult.data)
setIsAdmin(true)
} else if (
metricsResult.error !== "Permission denied"
) {
setIsAdmin(false)
}
setLoading(false)
}, [])
React.useEffect(() => {
loadData()
}, [loadData])
const SLIDER_MAX = 50
const filteredModelCount = React.useMemo(() => {
if (costCeiling === null) return null
let total = 0
for (const g of groups) {
for (const m of g.models) {
if (
outputCostPerMillion(
m.completionCost
) <= costCeiling
) {
total++
}
}
}
return total
}, [groups, costCeiling])
if (loading) {
return (
<div className="space-y-3">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-9 w-full" />
<div className="flex gap-0 rounded-md border overflow-hidden">
<Skeleton className="h-80 w-14 rounded-none" />
<Skeleton className="h-80 flex-1 rounded-none" />
</div>
</div>
)
}
return (
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-xs">Active Model</Label>
{activeConfig ? (
<div className="flex items-center gap-2">
<span className="text-sm">
{activeConfig.modelName}
</span>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
{activeConfig.provider}
</Badge>
</div>
) : (
<p className="text-muted-foreground text-xs">
Using default: {DEFAULT_MODEL_ID}
</p>
)}
</div>
{isAdmin && (
<>
<Separator />
<div className="space-y-3">
<Label className="text-xs">
Model Policy
</Label>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<p className="text-sm font-medium">
Allow user model selection
</p>
<p className="text-muted-foreground text-xs">
When off, all users use the model
set above.
</p>
</div>
<Switch
checked={allowUserSelection}
onCheckedChange={setAllowUserSelection}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
Maximum cost ($/M tokens)
</p>
<span className="text-sm font-medium tabular-nums">
{costCeiling === null
? "No limit"
: `$${costCeiling.toFixed(2)}/M`}
</span>
</div>
<Slider
min={0}
max={SLIDER_MAX}
step={0.1}
value={[costCeiling ?? SLIDER_MAX]}
onValueChange={([v]) =>
setCostCeiling(
v >= SLIDER_MAX ? null : v
)
}
/>
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
<span>$0</span>
<span>
{costCeiling !== null &&
filteredModelCount !== null
? `${filteredModelCount} models available`
: "All models available"}
</span>
<span>$50/M</span>
</div>
</div>
<Button
size="sm"
className="h-8"
disabled={policySaving}
onClick={async () => {
setPolicySaving(true)
const result =
await updateModelPolicy(
costCeiling !== null
? costCeiling.toFixed(2)
: null,
allowUserSelection
)
setPolicySaving(false)
if (result.success) {
toast.success("Policy updated")
loadData()
} else {
toast.error(
result.error ??
"Failed to save policy"
)
}
}}
>
{policySaving && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
)}
Save Policy
</Button>
</div>
<Separator />
<div className="space-y-1.5">
<Label className="text-xs">
Change Model
</Label>
<p className="text-muted-foreground text-xs">
Select a model from OpenRouter. Applies
to all users.
</p>
{modelsError ? (
<p className="text-destructive text-xs">
{modelsError}
</p>
) : (
<ModelPicker
groups={groups}
activeConfig={activeConfig}
onSaved={loadData}
maxCostPerMillion={costCeiling}
/>
)}
</div>
<Separator />
{metrics &&
metrics.totalRequests > 0 ? (
<UsageSection metrics={metrics} />
) : (
<div className="space-y-1.5">
<Label className="text-xs">Usage</Label>
<p className="text-muted-foreground text-xs">
No usage data yet.
</p>
</div>
)}
</>
)}
</div>
)
}

View File

@ -3,12 +3,14 @@ import * as schema from "./schema"
import * as netsuiteSchema from "./schema-netsuite"
import * as pluginSchema from "./schema-plugins"
import * as agentSchema from "./schema-agent"
import * as aiConfigSchema from "./schema-ai-config"
const allSchemas = {
...schema,
...netsuiteSchema,
...pluginSchema,
...agentSchema,
...aiConfigSchema,
}
export function getDb(d1: D1Database) {

73
src/db/schema-ai-config.ts Executable file
View File

@ -0,0 +1,73 @@
import {
sqliteTable,
text,
integer,
} from "drizzle-orm/sqlite-core"
import { users, agentConversations } from "./schema"
// singleton config row (id = "global")
export const agentConfig = sqliteTable("agent_config", {
id: text("id").primaryKey(),
modelId: text("model_id").notNull(),
modelName: text("model_name").notNull(),
provider: text("provider").notNull(),
promptCost: text("prompt_cost").notNull(),
completionCost: text("completion_cost").notNull(),
contextLength: integer("context_length").notNull(),
maxCostPerMillion: text("max_cost_per_million"),
allowUserSelection: integer("allow_user_selection")
.notNull()
.default(1),
updatedBy: text("updated_by")
.notNull()
.references(() => users.id),
updatedAt: text("updated_at").notNull(),
})
// per-user model preference
export const userModelPreference = sqliteTable(
"user_model_preference",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id),
modelId: text("model_id").notNull(),
promptCost: text("prompt_cost").notNull(),
completionCost: text("completion_cost").notNull(),
updatedAt: text("updated_at").notNull(),
}
)
// one row per streamText invocation
export const agentUsage = sqliteTable("agent_usage", {
id: text("id").primaryKey(),
conversationId: text("conversation_id")
.notNull()
.references(() => agentConversations.id, {
onDelete: "cascade",
}),
userId: text("user_id")
.notNull()
.references(() => users.id),
modelId: text("model_id").notNull(),
promptTokens: integer("prompt_tokens")
.notNull()
.default(0),
completionTokens: integer("completion_tokens")
.notNull()
.default(0),
totalTokens: integer("total_tokens")
.notNull()
.default(0),
estimatedCost: text("estimated_cost").notNull(),
createdAt: text("created_at").notNull(),
})
export type AgentConfig = typeof agentConfig.$inferSelect
export type NewAgentConfig = typeof agentConfig.$inferInsert
export type AgentUsage = typeof agentUsage.$inferSelect
export type NewAgentUsage = typeof agentUsage.$inferInsert
export type UserModelPreference =
typeof userModelPreference.$inferSelect
export type NewUserModelPreference =
typeof userModelPreference.$inferInsert

View File

@ -13,6 +13,7 @@ import {
} from "@/lib/agent/chat-adapter"
interface UseCompassChatOptions {
readonly conversationId?: string | null
readonly onFinish?: (params: {
messages: ReadonlyArray<UIMessage>
}) => void | Promise<void>
@ -37,6 +38,8 @@ export function useCompassChat(options?: UseCompassChatOptions) {
"x-current-page": pathname,
"x-timezone":
Intl.DateTimeFormat().resolvedOptions().timeZone,
"x-conversation-id":
options?.conversationId ?? "",
},
}),
onFinish: options?.onFinish,

View File

@ -1,7 +1,69 @@
import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import { getCloudflareContext } from "@opennextjs/cloudflare"
import { eq } from "drizzle-orm"
import { getDb } from "@/db"
import {
agentConfig,
userModelPreference,
} from "@/db/schema-ai-config"
const MODEL_ID = "qwen/qwen3-coder-next"
export const DEFAULT_MODEL_ID = "qwen/qwen3-coder-next"
export async function getActiveModelId(
db: ReturnType<typeof getDb>
): Promise<string> {
const config = await db
.select({ modelId: agentConfig.modelId })
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
return config?.modelId ?? DEFAULT_MODEL_ID
}
export function createModelFromId(
apiKey: string,
modelId: string
) {
const openrouter = createOpenRouter({ apiKey })
return openrouter(modelId, {
provider: { allow_fallbacks: false },
})
}
export async function resolveModelForUser(
db: ReturnType<typeof getDb>,
userId: string
): Promise<string> {
const config = await db
.select()
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
if (!config) return DEFAULT_MODEL_ID
const globalModelId = config.modelId
const ceiling = config.maxCostPerMillion
? parseFloat(config.maxCostPerMillion)
: null
const pref = await db
.select()
.from(userModelPreference)
.where(eq(userModelPreference.userId, userId))
.get()
if (!pref) return globalModelId
if (ceiling !== null) {
const outputPerMillion =
parseFloat(pref.completionCost) * 1_000_000
if (outputPerMillion > ceiling) return globalModelId
}
return pref.modelId
}
export async function getAgentModel() {
const { env } = await getCloudflareContext()
@ -14,10 +76,8 @@ export async function getAgentModel() {
)
}
const openrouter = createOpenRouter({ apiKey })
return openrouter(MODEL_ID, {
provider: {
allow_fallbacks: false,
},
})
const db = getDb(env.DB)
const modelId = await getActiveModelId(db)
return createModelFromId(apiKey, modelId)
}

58
src/lib/agent/usage.ts Executable file
View File

@ -0,0 +1,58 @@
import { eq } from "drizzle-orm"
import type { LanguageModelUsage } from "ai"
import type { getDb } from "@/db"
import { agentConfig, agentUsage } from "@/db/schema-ai-config"
interface StreamResult {
readonly totalUsage: PromiseLike<LanguageModelUsage>
}
export async function saveStreamUsage(
db: ReturnType<typeof getDb>,
conversationId: string,
userId: string,
modelId: string,
result: StreamResult
): Promise<void> {
try {
const usage = await result.totalUsage
const promptTokens = usage.inputTokens ?? 0
const completionTokens = usage.outputTokens ?? 0
const totalTokens = usage.totalTokens ?? 0
const config = await db
.select({
promptCost: agentConfig.promptCost,
completionCost: agentConfig.completionCost,
})
.from(agentConfig)
.where(eq(agentConfig.id, "global"))
.get()
const promptRate = config
? parseFloat(config.promptCost)
: 0
const completionRate = config
? parseFloat(config.completionCost)
: 0
const estimatedCost =
promptTokens * promptRate +
completionTokens * completionRate
await db.insert(agentUsage).values({
id: crypto.randomUUID(),
conversationId,
userId,
modelId,
promptTokens,
completionTokens,
totalTokens,
estimatedCost: estimatedCost.toFixed(8),
createdAt: new Date().toISOString(),
})
} catch {
// usage tracking must never break the chat
}
}

View File

@ -13,6 +13,7 @@ export type Resource =
| "customer"
| "vendor"
| "finance"
| "agent"
export type Action = "create" | "read" | "update" | "delete" | "approve"
@ -36,6 +37,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["create", "read", "update", "delete"],
vendor: ["create", "read", "update", "delete"],
finance: ["create", "read", "update", "delete", "approve"],
agent: ["create", "read", "update", "delete"],
},
office: {
project: ["create", "read", "update"],
@ -50,6 +52,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["create", "read", "update"],
vendor: ["create", "read", "update"],
finance: ["create", "read", "update"],
agent: ["read"],
},
field: {
project: ["read"],
@ -64,6 +67,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["read"],
vendor: ["read"],
finance: ["read"],
agent: ["read"],
},
client: {
project: ["read"],
@ -78,6 +82,7 @@ const PERMISSIONS: RolePermissions = {
customer: ["read"],
vendor: ["read"],
finance: ["read"],
agent: [],
},
}