diff --git a/example-project/package-lock.json b/example-project/package-lock.json index 86f12303c3fba525fa9ee1715a2f20701bbbc26d..fd5c578392a548f5a91b4edabd5bcbccde59b55b 100644 --- a/example-project/package-lock.json +++ b/example-project/package-lock.json @@ -20,7 +20,7 @@ }, "..": { "name": "@peerverity/ai-delegate", - "version": "0.2.4", + "version": "0.3.2", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.71.0", diff --git a/example-project/public/index.html b/example-project/public/index.html index b49bec1952a89616fa78649ad41dcd3d3f4bff54..432f6b6ca362d303c7ac9de6357a0ac02d28c289 100644 --- a/example-project/public/index.html +++ b/example-project/public/index.html @@ -232,6 +232,16 @@
Streaming output will appear here...
+
+

Fetch Models

+

Test fetching available models from the Anthropic API

+
+ + +
+
Model list will appear here...
+
+ diff --git a/example-project/src/index.js b/example-project/src/index.js index 88193f7aa31942ad221434db5d4072981eeb8e81..ae975533c2b676dbddd475510ca551416398e761 100644 --- a/example-project/src/index.js +++ b/example-project/src/index.js @@ -157,6 +157,34 @@ function clearStream() { document.getElementById('stream-output').textContent = 'Streaming output will appear here...'; } +// ===== Fetch Models ===== +async function fetchModels() { + const outputEl = document.getElementById('models-output'); + outputEl.innerHTML = 'Fetching models from Anthropic API...'; + + try { + const models = await AiDelegate.fetchModels(); + + if (models.length === 0) { + outputEl.innerHTML = 'No models returned from API'; + return; + } + + const modelList = models.map(m => { + const date = new Date(m.created_at).toLocaleDateString(); + return `${m.id}\n Display: ${m.display_name}\n Created: ${date}\n Type: ${m.type}`; + }).join('\n\n'); + + outputEl.textContent = `Found ${models.length} models:\n\n${modelList}`; + } catch (error) { + outputEl.innerHTML = `Error: ${escapeHtml(error.message)}`; + } +} + +function clearModels() { + document.getElementById('models-output').textContent = 'Model list will appear here...'; +} + // ===== Initialization ===== async function initialize() { updateStatus('pending', 'Initializing...'); @@ -179,6 +207,8 @@ window.resetAll = resetAll; window.sendMessage = sendMessage; window.streamResponse = streamResponse; window.clearStream = clearStream; +window.fetchModels = fetchModels; +window.clearModels = clearModels; // Start initialization when DOM is ready if (document.readyState === 'loading') { diff --git a/package.json b/package.json index d5a8efcbb2b53ace31532457e21e680f55b839dc..033d59242dd740bfbbe7d43d69ad32cc9d695d40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@peerverity/ai-delegate", - "version": "0.3.0", + "version": "0.3.3", "description": "AI request manager with credential management and weight-based model selection", "main": "dist/ai-delegate.js", "types": "dist/index.d.ts", diff --git a/src/core/AiDelegate.ts b/src/core/AiDelegate.ts index 83e6f8135b6332e3d94f619abeb5242b3239cbe5..54891b6a4327011886de81b66fce49c1d0dc2bd1 100644 --- a/src/core/AiDelegate.ts +++ b/src/core/AiDelegate.ts @@ -4,7 +4,8 @@ import { AiDelegateConfig, AiDelegateStatus, AiDelegateInitOptions, - Credentials + Credentials, + ModelInfo } from '../types'; import { WorkerWrapper } from '../worker/WorkerWrapper'; import { @@ -200,8 +201,19 @@ export class AiDelegate { switch (request.promptType) { case PromptType.CONFIGURE: - // Show configuration dialog - data = await CredentialUI.promptForConfiguration(); + // Show configuration dialog - pass mode information for correct dialog type + const setupUrl = await this.worker.getSetupUrl(); + const allowLocalMode = this.allowLocalMode; + const isLocalModeActive = await this.worker.isLocalModeForced(); + const status = await this.worker.getStatus(); + const hasLocalCredentials = isLocalModeActive && status.hasCredentials; + + data = await CredentialUI.promptForConfiguration( + setupUrl, + allowLocalMode, + isLocalModeActive, + hasLocalCredentials + ); break; case PromptType.UNLOCK: @@ -300,6 +312,15 @@ export class AiDelegate { return this.worker.isReady(); } + /** + * Fetch available models from the provider + * This calls the provider's API to get the list of models + */ + async fetchModels(): Promise { + await this.ensureWorkerInitialized(); + return this.worker.fetchModels(); + } + /** * Unlock with password */ diff --git a/src/core/AiDelegateCore.ts b/src/core/AiDelegateCore.ts index 9c29f90e8fcd7ee815d9130c5ae4a4972c12b043..79772f801e21e73acbd05b9f91082370650c58e0 100644 --- a/src/core/AiDelegateCore.ts +++ b/src/core/AiDelegateCore.ts @@ -163,6 +163,36 @@ export class AiDelegateCore { return `${this.credentialServerUrl}/setup`; } + /** + * Fetch available models from the provider + * This calls the provider's API to get the list of models + */ + async fetchAvailableModels(): Promise { + await this.ensureInitialized(); + + if (!this.provider) { + throw new Error('Provider not configured'); + } + + // For proxied providers, credentials are managed by the server + // Use a placeholder if no credentials are set (proxied mode) + const creds: Credentials = this.credentials || { apiKey: 'proxied' }; + console.log('[AiDelegateCore] Fetching available models from provider'); + const models = await this.provider.fetchAvailableModels(creds); + console.log('[AiDelegateCore] Fetched models:', models.map(m => m.id)); + return models; + } + + /** + * Ensure initialization is complete + * @private + */ + private async ensureInitialized(): Promise { + if (!this.isInitialized) { + await this.initialize(); + } + } + /** * Initialize from stored configuration (called automatically on first use) * Loads metadata only - credentials remain locked until unlocked diff --git a/src/index.ts b/src/index.ts index 9e90a237b46888d98c07d87d7f4782253644f215..2962b3d5fb6cf9393df660bcd0f524f9be38657c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,8 @@ import { PromptRegistration, StoredPrompt, RegisterResult, - RenderedPrompt + RenderedPrompt, + ModelInfo } from './types'; /** @@ -98,6 +99,14 @@ const api = { return AiDelegate.getInstance().isReady(); }, + /** + * Fetch available models from the provider + * Calls the provider's API to get the list of available models + */ + fetchModels: (): Promise => { + return AiDelegate.getInstance().fetchModels(); + }, + /** * Open the configuration settings dialog */ @@ -228,7 +237,8 @@ export type { PromptRegistration, StoredPrompt, RegisterResult, - RenderedPrompt + RenderedPrompt, + ModelInfo }; // Export Context and ModalHelper classes for TypeScript consumers diff --git a/src/providers/ProxiedAnthropicProvider.ts b/src/providers/ProxiedAnthropicProvider.ts index b056fee541ce78a06e802ef04d6aad43ac78a9f8..dc070f3163f63537481cd20153d77a0474766197 100644 --- a/src/providers/ProxiedAnthropicProvider.ts +++ b/src/providers/ProxiedAnthropicProvider.ts @@ -58,22 +58,35 @@ export class ProxiedAnthropicProvider extends Provider { } /** - * Fetch available models via the proxy - * Note: Currently returns a static list since the proxy endpoint - * would need to be extended to support model listing + * Fetch available models via the proxy from the Anthropic API */ async fetchAvailableModels(_credentials: Credentials): Promise { - // For now, return commonly available models - // In future, this could be proxied through the credential server - const staticModels: ModelInfo[] = [ - { id: 'claude-sonnet-4-20250514', display_name: 'Claude Sonnet 4', created_at: '2025-05-14T00:00:00Z', type: 'model' }, - { id: 'claude-3-5-sonnet-20241022', display_name: 'Claude 3.5 Sonnet', created_at: '2024-10-22T00:00:00Z', type: 'model' }, - { id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku', created_at: '2024-10-22T00:00:00Z', type: 'model' }, - { id: 'claude-3-opus-20240229', display_name: 'Claude 3 Opus', created_at: '2024-02-29T00:00:00Z', type: 'model' } - ]; - - this._availableModels = staticModels.map(m => m.id); - return staticModels; + try { + const response = await fetch(`${this.serverUrl}/proxy?path=/v1/models`, { + method: 'GET', + credentials: 'include' + }); + + if (!response.ok) { + throw new Error(`Failed to fetch models: HTTP ${response.status}`); + } + + const data = await response.json(); + + // Anthropic API returns { data: [{ id, display_name, created_at, type }] } + const models: ModelInfo[] = (data.data || []).map((m: any) => ({ + id: m.id, + display_name: m.display_name || m.id, + created_at: m.created_at || new Date().toISOString(), + type: m.type || 'model' + })); + + this._availableModels = models.map(m => m.id); + return models; + } catch (error: any) { + console.error('[ProxiedAnthropicProvider] Failed to fetch models from API:', error.message); + throw new Error(`Failed to fetch available models: ${error.message}`); + } } /** diff --git a/src/server/index.ts b/src/server/index.ts index 59fc30eb4eeefa497c20dade45b2de5a91576e62..566a88660bab63779e187a8ab88a1eac5ea0828e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -326,8 +326,8 @@ export function createHandler(options: AiDelegateServerOptions = {}) { return true; } - // Proxy endpoint - if (pathname === '/proxy' && method === 'POST') { + // Proxy endpoint (GET for models list, POST for API calls) + if (pathname === '/proxy' && (method === 'GET' || method === 'POST')) { await handleProxy(req, res, db); return true; } @@ -351,7 +351,7 @@ export function createHandler(options: AiDelegateServerOptions = {}) { res.setHeader('Content-Type', contentType); res.setHeader('Content-Length', stat.size); - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + res.setHeader('Cache-Control', 'no-cache'); const stream = fs.createReadStream(filePath); res.statusCode = 200; @@ -466,19 +466,28 @@ export function createHandler(options: AiDelegateServerOptions = {}) { } try { - const body = await readBody(req); + const method = req.method || 'POST'; const url = new URL(req.url || '/', `http://${req.headers.host}`); const targetPath = url.searchParams.get('path') || '/v1/messages'; const anthropicUrl = new URL(targetPath, ANTHROPIC_API_BASE); + // Only read body for POST requests + const body = method === 'POST' ? await readBody(req) : null; + + const headers: Record = { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01' + }; + + // Only set Content-Type for POST requests with body + if (method === 'POST') { + headers['Content-Type'] = 'application/json'; + } + const proxyReq = https.request(anthropicUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': apiKey, - 'anthropic-version': '2023-06-01' - } + method, + headers }, (proxyRes) => { const forwardHeaders: Record = {}; for (const [key, value] of Object.entries(proxyRes.headers)) { @@ -496,7 +505,9 @@ export function createHandler(options: AiDelegateServerOptions = {}) { sendJson(res, 502, { error: 'Failed to reach AI provider' }); }); - proxyReq.write(body); + if (body) { + proxyReq.write(body); + } proxyReq.end(); } catch (err) { console.error('[AiDelegate] Proxy error:', err); diff --git a/src/worker/WorkerWrapper.ts b/src/worker/WorkerWrapper.ts index b1e99b882309f0257aebe5ae4519ade694f2bfd5..5eb7262e4cd400761863331f9914205933f0dc01 100644 --- a/src/worker/WorkerWrapper.ts +++ b/src/worker/WorkerWrapper.ts @@ -3,7 +3,8 @@ import { AiResponse, AiDelegateConfig, AiDelegateStatus, - Credentials + Credentials, + ModelInfo } from '../types'; import { WorkerCommand, @@ -188,6 +189,13 @@ export class WorkerWrapper { return this.sendCommand(WorkerCommand.GET_SETUP_URL); } + /** + * Fetch available models from the provider + */ + async fetchModels(): Promise { + return this.sendCommand(WorkerCommand.FETCH_MODELS); + } + /** * Set local mode (forces direct API calls instead of proxied) */ diff --git a/src/worker/types.ts b/src/worker/types.ts index 89015930c07ba5574dffeb4cb246fef86b2fc833..bc77e5ee63524e580f6c510be3d6b2d4c0172720 100644 --- a/src/worker/types.ts +++ b/src/worker/types.ts @@ -3,7 +3,8 @@ import { AiResponse, AiDelegateConfig, AiDelegateStatus, - Credentials + Credentials, + ModelInfo } from '../types'; import { @@ -33,6 +34,8 @@ export enum WorkerCommand { IS_LOCAL_MODE_ALLOWED = 'isLocalModeAllowed', IS_LOCAL_MODE_FORCED = 'isLocalModeForced', CONFIGURE_LOCAL = 'configureLocal', + // Model fetching + FETCH_MODELS = 'fetchModels', // Prompt management commands REGISTER_PROMPT = 'registerPrompt', REGISTER_PROMPTS = 'registerPrompts', @@ -165,6 +168,8 @@ export type TypedWorkerMessage = | { id: string; command: WorkerCommand.IS_LOCAL_MODE_ALLOWED; payload?: undefined } | { id: string; command: WorkerCommand.IS_LOCAL_MODE_FORCED; payload?: undefined } | { id: string; command: WorkerCommand.CONFIGURE_LOCAL; payload: ConfigureLocalPayload } + // Model fetching + | { id: string; command: WorkerCommand.FETCH_MODELS; payload?: undefined } // Prompt management commands | { id: string; command: WorkerCommand.REGISTER_PROMPT; payload: RegisterPromptPayload } | { id: string; command: WorkerCommand.REGISTER_PROMPTS; payload: RegisterPromptsPayload } @@ -199,6 +204,10 @@ export interface GetSetupUrlResponse extends WorkerResponse { data?: string; } +export interface FetchModelsResponse extends WorkerResponse { + data?: ModelInfo[]; +} + /** * Prompt management responses */ diff --git a/src/worker/worker.ts b/src/worker/worker.ts index 101edb87d5e1f5107a7ede1cfb99f880b1c7f329..7738e5337a8f3335a5361cbab39eff09a1543230 100644 --- a/src/worker/worker.ts +++ b/src/worker/worker.ts @@ -14,7 +14,7 @@ import { PromptConflict } from '../types/prompts'; */ // Version marker for debugging cache issues -console.log('[AI Delegate Worker] Version: DEBUG-001'); +console.log('[AI Delegate Worker] Version: 0.3.2'); // Storage and config manager (initialized immediately) const storage = new IndexedDBAdapter(); @@ -228,6 +228,10 @@ async function handleCommand(message: TypedWorkerMessage) { const setupUrl = getDelegate().getCredentialServerSetupUrl(); return MessageProtocol.createSuccessResponse(id, setupUrl); + case WorkerCommand.FETCH_MODELS: + const models = await getDelegate().fetchAvailableModels(); + return MessageProtocol.createSuccessResponse(id, models); + // Local mode commands case WorkerCommand.SET_LOCAL_MODE: await getDelegate().setForceLocalMode(payload.enabled);