mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 01:03:49 +00:00
Add Anthropic prompt caching, pluggable storage, and CORS proxy support
Storage Architecture:
- New pluggable storage system with backends (LocalStorage, ChromeStorage, IndexedDB)
- SettingsRepository for app settings (proxy config, etc.)
- ProviderKeysRepository for API key management
- AppStorage with global accessors (getAppStorage, setAppStorage, initAppStorage)
Transport Refactoring:
- Renamed DirectTransport → ProviderTransport (calls LLM providers with optional CORS proxy)
- Renamed ProxyTransport → AppTransport (uses app server with user auth)
- Updated TransportMode: "direct" → "provider", "proxy" → "app"
CORS Proxy Integration:
- ProviderTransport checks proxy.enabled/proxy.url from storage
- When enabled, modifies model baseUrl to route through proxy: {proxyUrl}/?url={originalBaseUrl}
- ProviderKeyInput test function also honors proxy settings
- Settings dialog with Proxy tab (Switch toggle, URL input, explanatory description)
Anthropic Prompt Caching:
- System prompt cached with cache_control markers (both OAuth and regular API keys)
- Last user message cached to cache conversation history
- Saves 90% on input tokens for cached content (10x cost reduction)
Settings Dialog Improvements:
- Configurable tab system with SettingsTab base class
- ApiKeysTab and ProxyTab as custom elements
- Switch toggle for proxy enable (instead of Checkbox)
- Explanatory paragraphs for each tab
- ApiKeyPromptDialog reuses ProviderKeyInput component
Removed:
- Deprecated ApiKeysDialog (replaced by ProviderKeyInput in SettingsDialog)
- Old storage-adapter and key-store (replaced by new storage architecture)
This commit is contained in:
parent
66f092c0c6
commit
0496651308
31 changed files with 1141 additions and 488 deletions
170
packages/web-ui/src/components/ProviderKeyInput.ts
Normal file
170
packages/web-ui/src/components/ProviderKeyInput.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { Badge, Button, html, Input, i18n } from "@mariozechner/mini-lit";
|
||||
import { type Context, complete, getModel } from "@mariozechner/pi-ai";
|
||||
import { LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { getAppStorage } from "../storage/app-storage.js";
|
||||
|
||||
// Test models for each provider
|
||||
const TEST_MODELS: Record<string, string> = {
|
||||
anthropic: "claude-3-5-haiku-20241022",
|
||||
openai: "gpt-4o-mini",
|
||||
google: "gemini-2.5-flash",
|
||||
groq: "openai/gpt-oss-20b",
|
||||
openrouter: "z-ai/glm-4.6",
|
||||
cerebras: "gpt-oss-120b",
|
||||
xai: "grok-4-fast-non-reasoning",
|
||||
zai: "glm-4.5-air",
|
||||
};
|
||||
|
||||
@customElement("provider-key-input")
|
||||
export class ProviderKeyInput extends LitElement {
|
||||
@property() provider = "";
|
||||
@state() private keyInput = "";
|
||||
@state() private testing = false;
|
||||
@state() private failed = false;
|
||||
@state() private hasKey = false;
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.checkKeyStatus();
|
||||
}
|
||||
|
||||
private async checkKeyStatus() {
|
||||
try {
|
||||
const key = await getAppStorage().providerKeys.getKey(this.provider);
|
||||
this.hasKey = !!key;
|
||||
} catch (error) {
|
||||
console.error("Failed to check key status:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
|
||||
try {
|
||||
const modelId = TEST_MODELS[provider];
|
||||
if (!modelId) return false;
|
||||
|
||||
let model = getModel(provider as any, modelId);
|
||||
if (!model) return false;
|
||||
|
||||
// Check if CORS proxy is enabled and apply it
|
||||
const proxyEnabled = await getAppStorage().settings.get<boolean>("proxy.enabled");
|
||||
const proxyUrl = await getAppStorage().settings.get<string>("proxy.url");
|
||||
|
||||
if (proxyEnabled && proxyUrl && model.baseUrl) {
|
||||
model = {
|
||||
...model,
|
||||
baseUrl: `${proxyUrl}/?url=${encodeURIComponent(model.baseUrl)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const context: Context = {
|
||||
messages: [{ role: "user", content: "Reply with: ok" }],
|
||||
};
|
||||
|
||||
const result = await complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: 10,
|
||||
} as any);
|
||||
|
||||
return result.stopReason === "stop";
|
||||
} catch (error) {
|
||||
console.error(`API key test failed for ${provider}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveKey() {
|
||||
if (!this.keyInput) return;
|
||||
|
||||
this.testing = true;
|
||||
this.failed = false;
|
||||
|
||||
const success = await this.testApiKey(this.provider, this.keyInput);
|
||||
|
||||
this.testing = false;
|
||||
|
||||
if (success) {
|
||||
try {
|
||||
await getAppStorage().providerKeys.setKey(this.provider, this.keyInput);
|
||||
this.hasKey = true;
|
||||
this.keyInput = "";
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to save API key:", error);
|
||||
this.failed = true;
|
||||
setTimeout(() => {
|
||||
this.failed = false;
|
||||
this.requestUpdate();
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
this.failed = true;
|
||||
setTimeout(() => {
|
||||
this.failed = false;
|
||||
this.requestUpdate();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
private async removeKey() {
|
||||
try {
|
||||
await getAppStorage().providerKeys.removeKey(this.provider);
|
||||
this.hasKey = false;
|
||||
this.keyInput = "";
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error("Failed to remove API key:", error);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium capitalize text-foreground">${this.provider}</span>
|
||||
${
|
||||
this.testing
|
||||
? Badge({ children: i18n("Testing..."), variant: "secondary" })
|
||||
: this.hasKey
|
||||
? html`<span class="text-green-600 dark:text-green-400">✓</span>`
|
||||
: ""
|
||||
}
|
||||
${this.failed ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) : ""}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
${Input({
|
||||
type: "password",
|
||||
placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"),
|
||||
value: this.keyInput,
|
||||
onInput: (e: Event) => {
|
||||
this.keyInput = (e.target as HTMLInputElement).value;
|
||||
this.requestUpdate();
|
||||
},
|
||||
className: "flex-1",
|
||||
})}
|
||||
${
|
||||
this.hasKey
|
||||
? Button({
|
||||
onClick: () => this.removeKey(),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
children: i18n("Clear"),
|
||||
className: "!text-destructive",
|
||||
})
|
||||
: Button({
|
||||
onClick: () => this.saveKey(),
|
||||
variant: "default",
|
||||
size: "sm",
|
||||
disabled: !this.keyInput || this.testing,
|
||||
children: i18n("Save"),
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue