8 Commits

Author SHA1 Message Date
3d985a95a8 refactor: split OpenWebUI into strategy classes by backend
Replace Conditional with Polymorphism from the Refactoring Guru catalog.

The OpenWebUIProvider previously contained two parallel implementations
(completeWithChat / completeWithOllama, streamWithChat / streamWithOllama,
plus branching in validateConnection) selected by a `useOllamaProxy`
boolean. The 987-line file is split into:

  - openwebui-types.ts       wire-format response types
  - openwebui-http.ts        shared HTTP client (auth, timeout)
  - openwebui-strategies.ts  OpenWebUIStrategy interface plus
                             OpenWebUIChatStrategy and
                             OpenWebUIOllamaStrategy implementations
  - openwebui.ts             thin provider that picks one strategy at
                             construction and delegates

OpenWebUIProvider and OpenWebUIConfig (including useOllamaProxy) stay
in their original locations, so consumer imports are unchanged.

Side effects:
- Error mapping now goes through the base mapProviderError / providerErrorMessages
  hooks added in R2, removing handleOpenWebUIError and its duplicated status switch.
- providerInfo.version bumped to 2.0.0 to reflect the rewrite.
2026-05-21 13:51:08 +02:00
eacd76d259 Merge pull request 'refactor: pull validateConnection / validateModelName / error mapping into base' (#8) from refactor/template-method-pullup into main
Reviewed-on: #8
2026-05-21 11:46:55 +00:00
b36d57711b refactor: pull validateConnection / validateModelName / error mapping into base
Form Template Method / Pull Up Method from the Refactoring Guru catalog.

The three SDK-based providers (Claude, OpenAI, Gemini) each had their own
near-identical implementations of:

  - validateConnection: try a tiny request, catch errors, map a handful
    of patterns, warn on the rest
  - validateModelName: iterate regex patterns, warn on no match
  - handle{X}Error: switch on HTTP status, map to AIProviderError with
    brand-flavored messages

These collapse into three protected hooks on BaseAIProvider:

  - getModelNamePatterns(): RegExp[]
  - sendValidationProbe(): Promise<void>
  - mapProviderError(error): AIProviderError | null
  - providerErrorMessages(): Partial<Record<number, string>>

The base owns the wrapping logic (warning, status -> error-type map,
common message patterns). Providers only contribute their unique parts.

Side effects:

  - normalizeError now consults mapProviderError before generic mapping,
    so provider-specific patterns work via the existing try/catch in
    BaseAIProvider.complete/stream. The per-provider try/catch wrappers
    around doComplete/doStream are removed.
  - The unknown-error message becomes `${providerName} error: ${msg}`
    instead of the hardcoded `Provider error:` / `Claude API error:` etc.
  - OpenWebUI's HTTP-based validateConnection is preserved (override),
    since its non-SDK shape doesn't fit the SDK probe pattern.
2026-05-21 13:43:07 +02:00
8e73296ac2 Merge pull request 'refactor: extract constants, parameterize validators, simplify factory' (#7) from refactor/quick-wins-factory-validators-constants into main
Reviewed-on: #7
2026-05-21 11:36:34 +00:00
bb37e61eaf refactor: extract constants, parameterize validators, simplify factory
Quick-win refactors from the Refactoring Guru catalog:

- Replace Magic Number with Symbolic Constant: extract DEFAULT_TIMEOUT_MS,
  DEFAULT_MAX_RETRIES, DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE, default
  models per provider, and validation prompt into src/constants.ts.
- Parameterize Method: collapse validateTemperature / validateTopP /
  validateMaxTokens (and the inline timeout/maxRetries checks) into a
  single validateNumberInRange helper with bounds metadata.
- Inline Class / Remove Middle Man: drop the unused ProviderRegistry
  class from utils/factory.ts. createProvider now dispatches through
  the PROVIDER_REGISTRY const directly instead of a parallel switch.

Also fixes stale VERSION constant in src/index.ts (was 1.3.1).
2026-05-21 13:35:12 +02:00
797fad1b00 chore(release): 2.0.0
Major release: @google/generative-ai → @google/genai migration changes
the type signature of GeminiConfig.safetySettings, which is a breaking
change for consumers using that option.

Other changes accumulated since 1.3.3:
- @anthropic-ai/sdk 0.65 → 0.97
- openai 4 → 6
- @types/node 24 → 25
- TypeScript 6 support (peer widened to ^5 || ^6)
- Default Gemini model: gemini-1.5-flash → gemini-2.5-flash
2026-05-21 12:56:46 +02:00
fbe66623a1 Merge pull request 'chore(deps)!: migrate from @google/generative-ai to @google/genai' (#6) from chore/deps-google-genai-migration into main
Reviewed-on: #6
2026-05-21 10:56:04 +00:00
c658c67a0c chore(deps)!: migrate from @google/generative-ai to @google/genai
The @google/generative-ai SDK has been deprecated by Google. This switches
to the successor @google/genai package and rewrites the Gemini provider
against the new stateless models/chats API.

BREAKING CHANGE: GeminiConfig.safetySettings now uses the SafetySetting
type from @google/genai. Consumers passing this field must update their
import from '@google/generative-ai' to '@google/genai'. The shape of the
type is similar but not identical.

Notable simplifications enabled by the new SDK:
- No per-model client caching: generateContent specifies model per-call
- Single code path for both single- and multi-turn (full contents array
  is passed to generateContent directly; no more startChat branching)
- Stream chunks expose .text as a property (was .text() method)
- Stream iteration is direct on the response (no .stream sub-property)

Default model bumped from gemini-1.5-flash to gemini-2.5-flash.
2026-05-21 12:54:48 +02:00
13 changed files with 1259 additions and 2291 deletions

View File

@@ -5,7 +5,7 @@
"name": "simple-ai-provider", "name": "simple-ai-provider",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.97.0", "@anthropic-ai/sdk": "^0.97.0",
"@google/generative-ai": "^0.24.1", "@google/genai": "^2.5.0",
"openai": "^6.0.0", "openai": "^6.0.0",
}, },
"devDependencies": { "devDependencies": {
@@ -23,7 +23,27 @@
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@google/generative-ai": ["@google/generative-ai@0.24.1", "", {}, "sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q=="], "@google/genai": ["@google/genai@2.5.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
@@ -31,14 +51,68 @@
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="], "openai": ["openai@6.38.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g=="],
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"protobufjs": ["protobufjs@7.6.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ=="],
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
@@ -47,6 +121,10 @@
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
"bun-types/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], "bun-types/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="],
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],

View File

@@ -1,6 +1,6 @@
{ {
"name": "simple-ai-provider", "name": "simple-ai-provider",
"version": "1.3.3", "version": "2.0.0",
"description": "A simple and extensible AI provider package for easy integration of multiple AI services", "description": "A simple and extensible AI provider package for easy integration of multiple AI services",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.mjs", "module": "dist/index.mjs",
@@ -53,7 +53,7 @@
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.97.0", "@anthropic-ai/sdk": "^0.97.0",
"@google/generative-ai": "^0.24.1", "@google/genai": "^2.5.0",
"openai": "^6.0.0" "openai": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {

35
src/constants.ts Normal file
View File

@@ -0,0 +1,35 @@
/**
* Shared defaults used across all providers.
*/
export const DEFAULT_TIMEOUT_MS = 30_000;
export const DEFAULT_MAX_RETRIES = 3;
export const DEFAULT_MAX_TOKENS = 1000;
export const DEFAULT_TEMPERATURE = 0.7;
/**
* Minimal prompt used to validate API credentials on initialization.
*/
export const VALIDATION_PROMPT = 'Hi';
export const DEFAULT_MODELS = {
claude: 'claude-3-5-sonnet-20241022',
openai: 'gpt-4o',
gemini: 'gemini-2.5-flash',
openwebui: 'llama3.1:latest'
} as const;
export const DEFAULT_OPENWEBUI_BASE_URL = 'http://localhost:3000';
export const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
/**
* Bounds for configuration value validation.
*/
export const CONFIG_BOUNDS = {
timeoutMs: { min: 1000, max: 300_000 },
maxRetries: { min: 0, max: 10 },
temperature: { min: 0, max: 1 },
topP: { min: 0, max: 1, exclusiveMin: true },
maxTokens: { min: 1 }
} as const;

View File

@@ -58,4 +58,4 @@ export const SUPPORTED_PROVIDERS = ['claude', 'openai', 'gemini', 'openwebui'] a
/** /**
* Package version * Package version
*/ */
export const VERSION = '1.3.1'; export const VERSION = '2.0.0';

View File

@@ -23,6 +23,11 @@ import type {
ResponseType ResponseType
} from '../types/index.js'; } from '../types/index.js';
import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } from '../types/index.js'; import { AIProviderError, AIErrorType, generateResponseTypePrompt, parseAndValidateResponseType } from '../types/index.js';
import {
CONFIG_BOUNDS,
DEFAULT_MAX_RETRIES,
DEFAULT_TIMEOUT_MS
} from '../constants.js';
// ============================================================================ // ============================================================================
// ABSTRACT BASE PROVIDER CLASS // ABSTRACT BASE PROVIDER CLASS
@@ -283,10 +288,104 @@ export abstract class BaseAIProvider {
*/ */
protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>; protected abstract doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk>;
// ========================================================================
// PROTECTED HOOKS (subclass overrides; safe defaults provided)
// ========================================================================
/**
* Regex patterns that valid model names should match.
* Empty array (the default) skips validation entirely.
*/
protected getModelNamePatterns(): RegExp[] {
return [];
}
/**
* Send a minimal request to verify the API key and connectivity.
* The default does nothing; SDK-based providers override this and the
* base `validateConnection` handles error mapping.
*/
protected async sendValidationProbe(): Promise<void> {
return;
}
/**
* Map a provider-specific error to a normalized AIProviderError, or
* return null to let the base error mapping handle it generically.
* Override to recognize provider-specific message patterns (e.g. "API
* key", "quota", "model not found").
*/
protected mapProviderError(_error: any): AIProviderError | null {
return null;
}
/**
* Per-HTTP-status message overrides used by `normalizeError` when no
* provider-specific mapping applies. Lets providers add brand-flavored
* hints (console URLs, etc.) without re-implementing the status switch.
*/
protected providerErrorMessages(): Partial<Record<number, string>> {
return {};
}
/**
* Human-readable provider name used in warning messages.
* Default derives from getInfo(); override if construction order makes
* getInfo() unsafe to call here.
*/
protected get providerName(): string {
try {
return this.getInfo().name;
} catch {
return 'Provider';
}
}
// ======================================================================== // ========================================================================
// PROTECTED UTILITY METHODS // PROTECTED UTILITY METHODS
// ======================================================================== // ========================================================================
/**
* Validates a model name against the patterns returned by
* `getModelNamePatterns()`. Throws for invalid input, warns for
* non-matching names (since model lists change frequently).
*/
protected validateModelName(modelName: string): void {
if (!modelName || typeof modelName !== 'string') {
throw new AIProviderError(
'Model name must be a non-empty string',
AIErrorType.INVALID_REQUEST
);
}
const patterns = this.getModelNamePatterns();
if (patterns.length === 0) return;
const matches = patterns.some(pattern => pattern.test(modelName));
if (!matches) {
console.warn(
`Model name '${modelName}' doesn't match expected ${this.providerName} naming patterns. This may cause API errors.`
);
}
}
/**
* Wraps `sendValidationProbe()` with consistent error handling: throws
* AIProviderError for provider-recognized failures, warns and continues
* for transient ones.
*/
protected async validateConnection(): Promise<void> {
try {
await this.sendValidationProbe();
} catch (error: any) {
const mapped = this.mapProviderError(error);
if (mapped) {
throw mapped;
}
console.warn(`${this.providerName} connection validation warning:`, error.message);
}
}
/** /**
* Validates and normalizes provider configuration. * Validates and normalizes provider configuration.
* *
@@ -314,60 +413,78 @@ export abstract class BaseAIProvider {
); );
} }
// Apply defaults and return normalized config this.validateNumberInRange('timeout', config.timeout, {
...CONFIG_BOUNDS.timeoutMs,
label: `${CONFIG_BOUNDS.timeoutMs.min}ms and ${CONFIG_BOUNDS.timeoutMs.max}ms`
});
this.validateNumberInRange('maxRetries', config.maxRetries, {
...CONFIG_BOUNDS.maxRetries,
integer: true
});
return { return {
...config, ...config,
timeout: this.validateTimeout(config.timeout), timeout: config.timeout ?? DEFAULT_TIMEOUT_MS,
maxRetries: this.validateMaxRetries(config.maxRetries) maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES
}; };
} }
/** /**
* Validates timeout configuration value. * Validates an optional numeric parameter against a range.
* * Skips validation when value is undefined so callers can apply defaults afterward.
* @private
* @param timeout - Timeout value to validate
* @returns Validated timeout value with default if needed
*/ */
private validateTimeout(timeout?: number): number { private validateNumberInRange(
const defaultTimeout = 30000; // 30 seconds name: string,
value: number | undefined,
if (timeout === undefined) { bounds: {
return defaultTimeout; min?: number;
max?: number;
exclusiveMin?: boolean;
integer?: boolean;
label?: string;
} }
): void {
if (value === undefined) return;
if (typeof timeout !== 'number' || timeout < 1000 || timeout > 300000) { const { min, max, exclusiveMin, integer, label } = bounds;
const range = label ?? this.describeRange(min, max, exclusiveMin);
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new AIProviderError( throw new AIProviderError(
'Timeout must be a number between 1000ms (1s) and 300000ms (5min)', `${name} must be a number between ${range}`,
AIErrorType.INVALID_REQUEST AIErrorType.INVALID_REQUEST
); );
} }
return timeout; if (integer && !Number.isInteger(value)) {
throw new AIProviderError(
`${name} must be an integer`,
AIErrorType.INVALID_REQUEST
);
}
if (min !== undefined && (exclusiveMin ? value <= min : value < min)) {
throw new AIProviderError(
`${name} must be ${exclusiveMin ? 'greater than' : 'at least'} ${min}`,
AIErrorType.INVALID_REQUEST
);
}
if (max !== undefined && value > max) {
throw new AIProviderError(
`${name} must be at most ${max}`,
AIErrorType.INVALID_REQUEST
);
}
} }
/** private describeRange(min?: number, max?: number, exclusiveMin?: boolean): string {
* Validates max retries configuration value. if (min !== undefined && max !== undefined) {
* return `${exclusiveMin ? '>' : '>='}${min} and <=${max}`;
* @private
* @param maxRetries - Max retries value to validate
* @returns Validated max retries value with default if needed
*/
private validateMaxRetries(maxRetries?: number): number {
const defaultMaxRetries = 3;
if (maxRetries === undefined) {
return defaultMaxRetries;
} }
if (min !== undefined) return `${exclusiveMin ? '>' : '>='}${min}`;
if (typeof maxRetries !== 'number' || maxRetries < 0 || maxRetries > 10) { if (max !== undefined) return `<=${max}`;
throw new AIProviderError( return 'a valid number';
'Max retries must be a number between 0 and 10',
AIErrorType.INVALID_REQUEST
);
}
return maxRetries;
} }
/** /**
@@ -403,13 +520,11 @@ export abstract class BaseAIProvider {
); );
} }
// Validate messages array
this.validateMessages(params.messages); this.validateMessages(params.messages);
// Validate optional parameters this.validateNumberInRange('temperature', params.temperature, CONFIG_BOUNDS.temperature);
this.validateTemperature(params.temperature); this.validateNumberInRange('topP', params.topP, CONFIG_BOUNDS.topP);
this.validateTopP(params.topP); this.validateNumberInRange('maxTokens', params.maxTokens, { ...CONFIG_BOUNDS.maxTokens, integer: true });
this.validateMaxTokens(params.maxTokens);
this.validateStopSequences(params.stopSequences); this.validateStopSequences(params.stopSequences);
} }
@@ -456,67 +571,6 @@ export abstract class BaseAIProvider {
} }
} }
/**
* Validates temperature parameter.
*
* @private
* @param temperature - Temperature value to validate
* @throws {AIProviderError} If temperature is invalid
*/
private validateTemperature(temperature?: number): void {
if (temperature !== undefined) {
if (typeof temperature !== 'number' || temperature < 0 || temperature > 1) {
throw new AIProviderError(
'Temperature must be a number between 0.0 and 1.0',
AIErrorType.INVALID_REQUEST
);
}
}
}
/**
* Validates top-p parameter.
*
* @private
* @param topP - Top-p value to validate
* @throws {AIProviderError} If top-p is invalid
*/
private validateTopP(topP?: number): void {
if (topP !== undefined) {
if (typeof topP !== 'number' || topP <= 0 || topP > 1) {
throw new AIProviderError(
'Top-p must be a number between 0.0 (exclusive) and 1.0 (inclusive)',
AIErrorType.INVALID_REQUEST
);
}
}
}
/**
* Validates max tokens parameter.
*
* @private
* @param maxTokens - Max tokens value to validate
* @throws {AIProviderError} If max tokens is invalid
*/
private validateMaxTokens(maxTokens?: number): void {
if (maxTokens !== undefined) {
if (typeof maxTokens !== 'number' || !Number.isInteger(maxTokens) || maxTokens < 1) {
throw new AIProviderError(
'Max tokens must be a positive integer',
AIErrorType.INVALID_REQUEST
);
}
}
}
/**
* Validates stop sequences parameter.
*
* @private
* @param stopSequences - Stop sequences to validate
* @throws {AIProviderError} If stop sequences are invalid
*/
private validateStopSequences(stopSequences?: string[]): void { private validateStopSequences(stopSequences?: string[]): void {
if (stopSequences !== undefined) { if (stopSequences !== undefined) {
if (!Array.isArray(stopSequences)) { if (!Array.isArray(stopSequences)) {
@@ -587,65 +641,52 @@ export abstract class BaseAIProvider {
* @returns Normalized AIProviderError with appropriate type and context * @returns Normalized AIProviderError with appropriate type and context
*/ */
protected normalizeError(error: Error): AIProviderError { protected normalizeError(error: Error): AIProviderError {
// If already normalized, return as-is
if (error instanceof AIProviderError) { if (error instanceof AIProviderError) {
return error; return error;
} }
// Extract status code if available const providerError = this.mapProviderError(error);
const status = (error as any).status || (error as any).statusCode; if (providerError) {
const message = error.message || 'Unknown error occurred'; return providerError;
}
// Map HTTP status codes to error types
if (status) { const status = (error as any).status || (error as any).statusCode;
switch (status) { const message = error.message || 'Unknown error occurred';
case 400: const overrides = this.providerErrorMessages();
return new AIProviderError(
`Bad request: ${message}`, const statusTypeMap: Record<number, AIErrorType> = {
AIErrorType.INVALID_REQUEST, 400: AIErrorType.INVALID_REQUEST,
status, 401: AIErrorType.AUTHENTICATION,
error 403: AIErrorType.AUTHENTICATION,
); 404: AIErrorType.MODEL_NOT_FOUND,
429: AIErrorType.RATE_LIMIT,
case 401: 500: AIErrorType.NETWORK,
case 403: 502: AIErrorType.NETWORK,
return new AIProviderError( 503: AIErrorType.NETWORK,
'Authentication failed. Please verify your API key is correct and has the necessary permissions.', 504: AIErrorType.NETWORK
AIErrorType.AUTHENTICATION, };
status,
error const defaultMessages: Record<number, string> = {
); 400: `Bad request: ${message}`,
401: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
case 404: 403: 'Authentication failed. Please verify your API key is correct and has the necessary permissions.',
return new AIProviderError( 404: 'The specified model or endpoint was not found. Please check the model name and availability.',
'The specified model or endpoint was not found. Please check the model name and availability.', 429: 'Rate limit exceeded. Please reduce your request frequency and try again later.',
AIErrorType.MODEL_NOT_FOUND, 500: 'Service temporarily unavailable. Please try again in a few moments.',
status, 502: 'Service temporarily unavailable. Please try again in a few moments.',
error 503: 'Service temporarily unavailable. Please try again in a few moments.',
); 504: 'Service temporarily unavailable. Please try again in a few moments.'
};
case 429:
return new AIProviderError( if (status && statusTypeMap[status]) {
'Rate limit exceeded. Please reduce your request frequency and try again later.', return new AIProviderError(
AIErrorType.RATE_LIMIT, overrides[status] ?? defaultMessages[status]!,
status, statusTypeMap[status]!,
error status,
); error
);
case 500:
case 502:
case 503:
case 504:
return new AIProviderError(
'Service temporarily unavailable. Please try again in a few moments.',
AIErrorType.NETWORK,
status,
error
);
}
} }
// Map common error patterns
if (message.includes('timeout') || message.includes('ETIMEDOUT')) { if (message.includes('timeout') || message.includes('ETIMEDOUT')) {
return new AIProviderError( return new AIProviderError(
'Request timed out. The operation took longer than expected.', 'Request timed out. The operation took longer than expected.',
@@ -664,9 +705,8 @@ export abstract class BaseAIProvider {
); );
} }
// Default to unknown error type
return new AIProviderError( return new AIProviderError(
`Provider error: ${message}`, `${this.providerName} error: ${message}`,
AIErrorType.UNKNOWN, AIErrorType.UNKNOWN,
status, status,
error error

View File

@@ -34,6 +34,13 @@ import type {
} from '../types/index.js'; } from '../types/index.js';
import { BaseAIProvider } from './base.js'; import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js'; import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_ANTHROPIC_VERSION,
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================ // ============================================================================
// TYPES AND INTERFACES // TYPES AND INTERFACES
@@ -153,9 +160,8 @@ export class ClaudeProvider extends BaseAIProvider {
constructor(config: ClaudeConfig) { constructor(config: ClaudeConfig) {
super(config); super(config);
// Set Claude-specific defaults this.defaultModel = config.defaultModel || DEFAULT_MODELS.claude;
this.defaultModel = config.defaultModel || 'claude-3-5-sonnet-20241022'; this.version = config.version || DEFAULT_ANTHROPIC_VERSION;
this.version = config.version || '2023-06-01';
// Validate model name format // Validate model name format
this.validateModelName(this.defaultModel); this.validateModelName(this.defaultModel);
@@ -178,7 +184,6 @@ export class ClaudeProvider extends BaseAIProvider {
*/ */
protected async doInitialize(): Promise<void> { protected async doInitialize(): Promise<void> {
try { try {
// Create Anthropic client with optimized configuration
this.client = new Anthropic({ this.client = new Anthropic({
apiKey: this.config.apiKey, apiKey: this.config.apiKey,
baseURL: this.config.baseUrl, baseURL: this.config.baseUrl,
@@ -186,17 +191,14 @@ export class ClaudeProvider extends BaseAIProvider {
maxRetries: this.config.maxRetries, maxRetries: this.config.maxRetries,
defaultHeaders: { defaultHeaders: {
'anthropic-version': this.version, 'anthropic-version': this.version,
'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15', // Enable extended context 'anthropic-beta': 'max-tokens-3-5-sonnet-2024-07-15',
} }
}); });
// Validate connection and permissions
await this.validateConnection(); await this.validateConnection();
} catch (error) { } catch (error) {
// Clean up on failure
this.client = null; this.client = null;
throw new AIProviderError( throw new AIProviderError(
`Failed to initialize Claude provider: ${(error as Error).message}`, `Failed to initialize Claude provider: ${(error as Error).message}`,
AIErrorType.AUTHENTICATION, AIErrorType.AUTHENTICATION,
@@ -225,22 +227,10 @@ export class ClaudeProvider extends BaseAIProvider {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { system, messages } = this.processMessages(params.messages);
// Process messages for Claude's format requirements const requestParams = this.buildRequestParams(params, system, messages, false);
const { system, messages } = this.processMessages(params.messages); const response = await this.client.messages.create(requestParams);
return this.formatCompletionResponse(response);
// Build optimized request parameters
const requestParams = this.buildRequestParams(params, system, messages, false);
// Make API request
const response = await this.client.messages.create(requestParams);
// Format and return response
return this.formatCompletionResponse(response);
} catch (error) {
throw this.handleAnthropicError(error as Error);
}
} }
/** /**
@@ -262,22 +252,10 @@ export class ClaudeProvider extends BaseAIProvider {
throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Claude client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { system, messages } = this.processMessages(params.messages);
// Process messages for Claude's format requirements const requestParams = this.buildRequestParams(params, system, messages, true);
const { system, messages } = this.processMessages(params.messages); const stream = await this.client.messages.create(requestParams);
yield* this.processStreamChunks(stream);
// Build streaming request parameters
const requestParams = this.buildRequestParams(params, system, messages, true);
// Create streaming request
const stream = await this.client.messages.create(requestParams);
// Process stream chunks
yield* this.processStreamChunks(stream);
} catch (error) {
throw this.handleAnthropicError(error as Error);
}
} }
// ======================================================================== // ========================================================================
@@ -317,76 +295,62 @@ export class ClaudeProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS // PRIVATE UTILITY METHODS
// ======================================================================== // ========================================================================
/** protected override getModelNamePatterns(): RegExp[] {
* Validates the connection by making a minimal test request. return [
* /^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/,
* @private /^claude-instant-[0-9.]+$/,
* @throws {AIProviderError} If connection validation fails /^claude-[0-9.]+$/
*/ ];
private async validateConnection(): Promise<void> { }
protected override async sendValidationProbe(): Promise<void> {
if (!this.client) { if (!this.client) {
throw new Error('Client not initialized'); throw new Error('Client not initialized');
} }
await this.client.messages.create({
try { model: this.defaultModel,
// Make minimal request to test connection and permissions max_tokens: 1,
await this.client.messages.create({ messages: [{ role: 'user', content: VALIDATION_PROMPT }]
model: this.defaultModel, });
max_tokens: 1,
messages: [{ role: 'user', content: 'Hi' }]
});
} catch (error: any) {
// Handle specific validation errors
if (error.status === 401 || error.status === 403) {
throw new AIProviderError(
'Invalid Anthropic API key. Please verify your API key from https://console.anthropic.com/',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.status === 404 && error.message?.includes('model')) {
throw new AIProviderError(
`Model '${this.defaultModel}' is not available. Please check the model name or your API access.`,
AIErrorType.MODEL_NOT_FOUND,
error.status
);
}
// For other errors during validation, log but don't fail initialization
// They might be temporary network issues
console.warn('Claude connection validation warning:', error.message);
}
} }
/** protected override providerErrorMessages(): Partial<Record<number, string>> {
* Validates model name format for Claude models. return {
* 401: 'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
* @private 403: 'Access forbidden. Your API key may not have permission for this model or feature.',
* @param modelName - Model name to validate 404: 'Model not found. The specified Claude model may not be available to your account.',
* @throws {AIProviderError} If model name format is invalid 429: 'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.',
*/ 500: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
private validateModelName(modelName: string): void { 502: 'Anthropic service temporarily unavailable. Please try again in a few moments.',
if (!modelName || typeof modelName !== 'string') { 503: 'Anthropic service temporarily unavailable. Please try again in a few moments.'
throw new AIProviderError( };
'Model name must be a non-empty string', }
AIErrorType.INVALID_REQUEST
); protected override mapProviderError(error: any): AIProviderError | null {
if (!error) return null;
const message: string = error.message || '';
const status = error.status || error.statusCode;
if (status === 400) {
if (message.includes('max_tokens')) {
return new AIProviderError(
'Max tokens value is invalid. Must be between 1 and model limit.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('model')) {
return new AIProviderError(
'Invalid model specified. Please check model availability.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
}
} }
// Claude model names follow specific patterns return null;
const validPatterns = [
/^claude-3(?:-5)?-(?:opus|sonnet|haiku)-\d{8}$/, // e.g., claude-3-5-sonnet-20241022
/^claude-instant-[0-9.]+$/, // e.g., claude-instant-1.2
/^claude-[0-9.]+$/ // e.g., claude-2.1
];
const isValid = validPatterns.some(pattern => pattern.test(modelName));
if (!isValid) {
console.warn(`Model name '${modelName}' doesn't match expected Claude naming patterns. This may cause API errors.`);
}
} }
/** /**
@@ -449,8 +413,8 @@ export class ClaudeProvider extends BaseAIProvider {
) { ) {
return { return {
model: params.model || this.defaultModel, model: params.model || this.defaultModel,
max_tokens: params.maxTokens || 1000, max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? 0.7, temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP, top_p: params.topP,
stop_sequences: params.stopSequences, stop_sequences: params.stopSequences,
system: system || undefined, system: system || undefined,
@@ -564,118 +528,4 @@ export class ClaudeProvider extends BaseAIProvider {
}; };
} }
/** }
* Handles and transforms Anthropic-specific errors.
*
* This method maps Anthropic's error responses to our standardized
* error format, providing helpful context and suggestions.
*
* @private
* @param error - Original error from Anthropic API
* @returns Normalized AIProviderError
*/
private handleAnthropicError(error: any): AIProviderError {
if (error instanceof AIProviderError) {
return error;
}
const message = error.message || 'Unknown Anthropic API error';
const status = error.status || error.statusCode;
// Map Anthropic-specific error codes
switch (status) {
case 400:
if (message.includes('max_tokens')) {
return new AIProviderError(
'Max tokens value is invalid. Must be between 1 and model limit.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('model')) {
return new AIProviderError(
'Invalid model specified. Please check model availability.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
}
return new AIProviderError(
`Invalid request: ${message}`,
AIErrorType.INVALID_REQUEST,
status,
error
);
case 401:
return new AIProviderError(
'Authentication failed. Please check your Anthropic API key from https://console.anthropic.com/',
AIErrorType.AUTHENTICATION,
status,
error
);
case 403:
return new AIProviderError(
'Access forbidden. Your API key may not have permission for this model or feature.',
AIErrorType.AUTHENTICATION,
status,
error
);
case 404:
return new AIProviderError(
'Model not found. The specified Claude model may not be available to your account.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
case 429:
return new AIProviderError(
'Rate limit exceeded. Claude APIs have usage limits. Please wait before retrying.',
AIErrorType.RATE_LIMIT,
status,
error
);
case 500:
case 502:
case 503:
return new AIProviderError(
'Anthropic service temporarily unavailable. Please try again in a few moments.',
AIErrorType.NETWORK,
status,
error
);
default:
// Handle timeout and network errors
if (message.includes('timeout') || error.code === 'ETIMEDOUT') {
return new AIProviderError(
'Request timed out. Claude may be experiencing high load.',
AIErrorType.TIMEOUT,
status,
error
);
}
if (message.includes('network') || error.code === 'ECONNREFUSED') {
return new AIProviderError(
'Network error connecting to Anthropic servers.',
AIErrorType.NETWORK,
status,
error
);
}
return new AIProviderError(
`Claude API error: ${message}`,
AIErrorType.UNKNOWN,
status,
error
);
}
}
}

View File

@@ -1,32 +1,26 @@
/** /**
* Google Gemini Provider Implementation * Google Gemini Provider Implementation
* *
* This module provides integration with Google's Gemini models through the official * Integrates with Google's Gemini models through the official @google/genai SDK
* Generative AI SDK. Gemini offers cutting-edge AI capabilities including multimodal * (the successor to the deprecated @google/generative-ai package).
* understanding, advanced reasoning, and efficient text generation across various tasks. *
*
* Key Features: * Key Features:
* - Support for all Gemini model variants (Gemini 1.5 Pro, Flash, Pro Vision) * - Support for Gemini 1.5 / 2.0 / 2.5 model families
* - Advanced streaming support with real-time token delivery * - Streaming responses with real-time chunk delivery
* - Native multimodal capabilities (text, images, video) * - Multimodal capabilities and configurable safety filtering
* - Sophisticated safety settings and content filtering * - System instruction handling separate from conversation flow
* - Flexible generation configuration with fine-grained control *
*
* Gemini-Specific Considerations:
* - Uses "candidates" for response variants and "usageMetadata" for token counts
* - Supports system instructions as separate parameter (not in conversation)
* - Has sophisticated safety filtering with customizable thresholds
* - Provides extensive generation configuration options
* - Supports both single-turn and multi-turn conversations
* - Offers advanced reasoning and coding capabilities
*
* @author Jan-Marlon Leibl * @author Jan-Marlon Leibl
* @version 1.0.0
* @see https://ai.google.dev/docs * @see https://ai.google.dev/docs
*/ */
import { GoogleGenerativeAI, GenerativeModel } from '@google/generative-ai'; import { GoogleGenAI } from '@google/genai';
import type { Content, Part, GenerationConfig, SafetySetting } from '@google/generative-ai'; import type {
Content,
GenerateContentConfig,
GenerateContentResponse,
SafetySetting
} from '@google/genai';
import type { import type {
AIProviderConfig, AIProviderConfig,
CompletionParams, CompletionParams,
@@ -37,6 +31,12 @@ import type {
} from '../types/index.js'; } from '../types/index.js';
import { BaseAIProvider } from './base.js'; import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js'; import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================ // ============================================================================
// TYPES AND INTERFACES // TYPES AND INTERFACES
@@ -44,84 +44,39 @@ import { AIProviderError, AIErrorType } from '../types/index.js';
/** /**
* Configuration interface for Gemini provider with Google-specific options. * Configuration interface for Gemini provider with Google-specific options.
*
* @example
* ```typescript
* const config: GeminiConfig = {
* apiKey: process.env.GOOGLE_API_KEY!,
* defaultModel: 'gemini-1.5-pro',
* safetySettings: [
* {
* category: HarmCategory.HARM_CATEGORY_HARASSMENT,
* threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
* }
* ],
* generationConfig: {
* temperature: 0.7,
* topP: 0.8,
* topK: 40,
* maxOutputTokens: 2048
* }
* };
* ```
*/ */
export interface GeminiConfig extends AIProviderConfig { export interface GeminiConfig extends AIProviderConfig {
/** /**
* Default Gemini model to use for requests. * Default Gemini model to use for requests.
* *
* Recommended models: * Recommended models:
* - 'gemini-1.5-pro': Flagship model, best overall performance and multimodal * - 'gemini-2.5-flash': Latest fast model, great default
* - 'gemini-1.5-flash': Faster and cheaper, good for simple tasks * - 'gemini-2.5-pro': Flagship reasoning model
* - 'gemini-1.0-pro': Previous generation, cost-effective * - 'gemini-2.0-flash': Previous generation fast model
* - 'gemini-pro-vision': Specialized for vision tasks (legacy) * - 'gemini-1.5-pro': Long-context flagship from the 1.5 generation
* *
* @default 'gemini-1.5-flash' * @default 'gemini-2.5-flash'
*/ */
defaultModel?: string; defaultModel?: string;
/** /** Safety settings for content filtering and harm prevention. */
* Safety settings for content filtering and harm prevention.
*
* Gemini includes built-in safety filtering across multiple categories:
* - Harassment and bullying
* - Hate speech and discrimination
* - Sexually explicit content
* - Dangerous or harmful activities
*
* Each category can be configured with different blocking thresholds.
*
* @see https://ai.google.dev/docs/safety_setting
*/
safetySettings?: SafetySetting[]; safetySettings?: SafetySetting[];
/** /** Default generation configuration applied to every request. */
* Generation configuration for controlling output characteristics.
*
* This allows fine-tuned control over the generation process including
* creativity, diversity, length, and stopping conditions.
*
* @see https://ai.google.dev/docs/concepts#generation_configuration
*/
generationConfig?: { generationConfig?: {
/** Controls randomness in generation (0.0 to 1.0) */
temperature?: number; temperature?: number;
/** Controls nucleus sampling for diversity (0.0 to 1.0) */
topP?: number; topP?: number;
/** Controls top-k sampling for diversity (positive integer) */
topK?: number; topK?: number;
/** Maximum number of tokens to generate */
maxOutputTokens?: number; maxOutputTokens?: number;
/** Sequences that will stop generation when encountered */
stopSequences?: string[]; stopSequences?: string[];
}; };
} }
/** /**
* Processed messages structure for Gemini's conversation format. * Processed messages structure for Gemini's conversation format.
* Separates system instructions from conversational content.
*/ */
interface ProcessedMessages { interface ProcessedMessages {
/** System instruction for model behavior (if any) */ /** System instruction string (if any) */
systemInstruction?: string; systemInstruction?: string;
/** Conversation content in Gemini's format */ /** Conversation content in Gemini's format */
contents: Content[]; contents: Content[];
@@ -132,80 +87,24 @@ interface ProcessedMessages {
// ============================================================================ // ============================================================================
/** /**
* Google Gemini provider implementation. * Google Gemini provider implementation backed by @google/genai.
* *
* This class handles all interactions with Google's Gemini models through their * The new SDK is stateless w.r.t. model selection — each `generateContent` call
* official Generative AI SDK. It provides optimized handling of Gemini's unique * specifies the model directly, so this class only holds a single client.
* features including multimodal inputs, safety filtering, and advanced generation control.
*
* Usage Pattern:
* 1. Create instance with Google API key and configuration
* 2. Call initialize() to set up client and validate credentials
* 3. Use complete() or stream() for text generation
* 4. Handle any AIProviderError exceptions appropriately
*
* @example
* ```typescript
* const gemini = new GeminiProvider({
* apiKey: process.env.GOOGLE_API_KEY!,
* defaultModel: 'gemini-1.5-pro',
* generationConfig: {
* temperature: 0.7,
* maxOutputTokens: 2048
* }
* });
*
* await gemini.initialize();
*
* const response = await gemini.complete({
* messages: [
* { role: 'system', content: 'You are a helpful research assistant.' },
* { role: 'user', content: 'Explain quantum computing.' }
* ],
* maxTokens: 1000,
* temperature: 0.8
* });
* ```
*/ */
export class GeminiProvider extends BaseAIProvider { export class GeminiProvider extends BaseAIProvider {
// ======================================================================== private client: GoogleGenAI | null = null;
// INSTANCE PROPERTIES
// ========================================================================
/** Google Generative AI client instance (initialized during doInitialize) */
private client: GoogleGenerativeAI | null = null;
/** Default model instance for requests */
private model: GenerativeModel | null = null;
/** Default model identifier for requests */
private readonly defaultModel: string; private readonly defaultModel: string;
/** Safety settings for content filtering */
private readonly safetySettings?: SafetySetting[]; private readonly safetySettings?: SafetySetting[];
private readonly defaultGenerationConfig?: GeminiConfig['generationConfig'];
/** Generation configuration defaults */
private readonly generationConfig?: any;
// ========================================================================
// CONSTRUCTOR
// ========================================================================
/**
* Creates a new Gemini provider instance.
*
* @param config - Gemini-specific configuration options
* @throws {AIProviderError} If configuration validation fails
*/
constructor(config: GeminiConfig) { constructor(config: GeminiConfig) {
super(config); super(config);
// Set Gemini-specific defaults this.defaultModel = config.defaultModel || DEFAULT_MODELS.gemini;
this.defaultModel = config.defaultModel || 'gemini-1.5-flash';
this.safetySettings = config.safetySettings; this.safetySettings = config.safetySettings;
this.generationConfig = config.generationConfig; this.defaultGenerationConfig = config.generationConfig;
// Validate model name format
this.validateModelName(this.defaultModel); this.validateModelName(this.defaultModel);
} }
@@ -213,38 +112,14 @@ export class GeminiProvider extends BaseAIProvider {
// PROTECTED TEMPLATE METHOD IMPLEMENTATIONS // PROTECTED TEMPLATE METHOD IMPLEMENTATIONS
// ======================================================================== // ========================================================================
/**
* Initializes the Gemini provider by setting up the client and model.
*
* This method:
* 1. Creates the Google Generative AI client with API key
* 2. Sets up the default model with safety and generation settings
* 3. Tests the connection with a minimal API call
* 4. Validates API key permissions and model access
*
* @protected
* @throws {Error} If client creation or connection validation fails
*/
protected async doInitialize(): Promise<void> { protected async doInitialize(): Promise<void> {
try { try {
// Create Google Generative AI client this.client = new GoogleGenAI({ apiKey: this.config.apiKey });
this.client = new GoogleGenerativeAI(this.config.apiKey);
// Set up default model with configuration
this.model = this.client.getGenerativeModel({
model: this.defaultModel,
safetySettings: this.safetySettings,
generationConfig: this.generationConfig
});
// Validate connection and permissions
await this.validateConnection(); await this.validateConnection();
} catch (error) { } catch (error) {
// Clean up on failure
this.client = null; this.client = null;
this.model = null;
throw new AIProviderError( throw new AIProviderError(
`Failed to initialize Gemini provider: ${(error as Error).message}`, `Failed to initialize Gemini provider: ${(error as Error).message}`,
AIErrorType.AUTHENTICATION, AIErrorType.AUTHENTICATION,
@@ -254,151 +129,66 @@ export class GeminiProvider extends BaseAIProvider {
} }
} }
/**
* Generates a text completion using Gemini's generation API.
*
* This method:
* 1. Converts messages to Gemini's format with system instructions
* 2. Chooses between single-turn and multi-turn conversation modes
* 3. Makes API call with comprehensive error handling
* 4. Formats response to standard interface
*
* @protected
* @param params - Validated completion parameters
* @returns Promise resolving to formatted completion response
* @throws {Error} If API request fails
*/
protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> { protected async doComplete(params: CompletionParams): Promise<CompletionResponse<string>> {
if (!this.client || !this.model) { if (!this.client) {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { systemInstruction, contents } = this.convertMessages(params.messages);
// Get the model for this request (might be different from default) const model = params.model || this.defaultModel;
const model = this.getModelForRequest(params);
const { systemInstruction, contents } = this.convertMessages(params.messages); const response = await this.client.models.generateContent({
model,
// Choose appropriate generation method based on conversation length contents,
if (contents.length > 1) { config: this.buildConfig(params, systemInstruction)
// Multi-turn conversation - use chat session });
const chat = model.startChat({
history: contents.slice(0, -1), return this.formatCompletionResponse(response, model);
systemInstruction,
generationConfig: this.buildGenerationConfig(params)
});
const lastMessage = contents[contents.length - 1];
if (!lastMessage) {
throw new AIProviderError('No valid messages provided', AIErrorType.INVALID_REQUEST);
}
const result = await chat.sendMessage(lastMessage.parts);
return this.formatCompletionResponse(result.response, params.model || this.defaultModel);
} else {
// Single message - use generateContent
const result = await model.generateContent({
contents,
systemInstruction,
generationConfig: this.buildGenerationConfig(params)
});
return this.formatCompletionResponse(result.response, params.model || this.defaultModel);
}
} catch (error) {
throw this.handleGeminiError(error as Error);
}
} }
/**
* Generates a streaming text completion using Gemini's streaming API.
*
* This method:
* 1. Sets up appropriate streaming mode based on conversation type
* 2. Handles real-time stream chunks from Gemini
* 3. Tracks token usage throughout the stream
* 4. Yields formatted chunks with proper completion tracking
*
* @protected
* @param params - Validated completion parameters
* @returns AsyncIterable yielding completion chunks
* @throws {Error} If streaming request fails
*/
protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> { protected async *doStream<T = any>(params: CompletionParams<T>): AsyncIterable<CompletionChunk> {
if (!this.client || !this.model) { if (!this.client) {
throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('Gemini client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const { systemInstruction, contents } = this.convertMessages(params.messages);
// Get the model for this request const model = params.model || this.defaultModel;
const model = this.getModelForRequest(params);
const { systemInstruction, contents } = this.convertMessages(params.messages);
// Set up streaming based on conversation type
let stream;
if (contents.length > 1) {
// Multi-turn conversation
const chat = model.startChat({
history: contents.slice(0, -1),
systemInstruction,
generationConfig: this.buildGenerationConfig(params)
});
const lastMessage = contents[contents.length - 1];
if (!lastMessage) {
throw new AIProviderError('No valid messages provided', AIErrorType.INVALID_REQUEST);
}
stream = await chat.sendMessageStream(lastMessage.parts);
} else {
// Single message
stream = await model.generateContentStream({
contents,
systemInstruction,
generationConfig: this.buildGenerationConfig(params)
});
}
// Process stream chunks const stream = await this.client.models.generateContentStream({
yield* this.processStreamChunks(stream); model,
contents,
} catch (error) { config: this.buildConfig(params, systemInstruction)
throw this.handleGeminiError(error as Error); });
}
yield* this.processStreamChunks(stream);
} }
// ======================================================================== // ========================================================================
// PUBLIC INTERFACE METHODS // PUBLIC INTERFACE METHODS
// ======================================================================== // ========================================================================
/**
* Returns comprehensive information about the Gemini provider.
*
* @returns Provider information including models, capabilities, and limits
*/
public getInfo(): ProviderInfo { public getInfo(): ProviderInfo {
return { return {
name: 'Gemini', name: 'Gemini',
version: '1.0.0', version: '2.0.0',
models: [ models: [
'gemini-1.5-pro', // Latest flagship model 'gemini-2.5-pro',
'gemini-1.5-flash', // Fast and efficient variant 'gemini-2.5-flash',
'gemini-1.0-pro', // Previous generation 'gemini-2.0-flash',
'gemini-pro-vision', // Vision-specialized (legacy) 'gemini-1.5-pro',
'gemini-1.5-pro-vision', // Latest vision model 'gemini-1.5-flash'
'gemini-1.0-pro-latest', // Latest 1.0 variant
'gemini-1.5-pro-latest' // Latest 1.5 variant
], ],
maxContextLength: 1048576, // ~1M tokens for Gemini 1.5 maxContextLength: 1048576,
supportsStreaming: true, supportsStreaming: true,
capabilities: { capabilities: {
vision: true, // Advanced multimodal capabilities vision: true,
functionCalling: true, // Tool use and function calling functionCalling: true,
jsonMode: true, // Structured JSON output jsonMode: true,
systemMessages: true, // System instructions support systemMessages: true,
reasoning: true, // Strong reasoning capabilities reasoning: true,
codeGeneration: true, // Excellent at programming tasks codeGeneration: true,
multimodal: true, // Text, image, video understanding multimodal: true,
safetyFiltering: true // Built-in content safety safetyFiltering: true
} }
}; };
} }
@@ -407,129 +197,91 @@ export class GeminiProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS // PRIVATE UTILITY METHODS
// ======================================================================== // ========================================================================
/** protected override getModelNamePatterns(): RegExp[] {
* Validates the connection by making a minimal test request. return [
* /^gemini-2\.5-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
* @private /^gemini-2\.0-(?:pro|flash)(?:-lite)?(?:-\d{4}-\d{2}-\d{2})?$/,
* @throws {AIProviderError} If connection validation fails /^gemini-1\.5-pro(?:-latest|-vision)?$/,
*/ /^gemini-1\.5-flash(?:-latest|-8b)?$/,
private async validateConnection(): Promise<void> { /^gemini-1\.0-pro(?:-latest|-vision)?$/,
if (!this.model) { /^gemini-pro(?:-vision)?$/,
throw new Error('Model not initialized'); /^models\/gemini-.+$/
}
try {
// Make minimal request to test connection and permissions
await this.model.generateContent({
contents: [{ role: 'user', parts: [{ text: 'Hi' }] }],
generationConfig: { maxOutputTokens: 1 }
});
} catch (error: any) {
// Handle specific validation errors
if (error.message?.includes('API key')) {
throw new AIProviderError(
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.message?.includes('quota') || error.message?.includes('billing')) {
throw new AIProviderError(
'API quota exceeded or billing issue. Please check your Google Cloud billing and API quotas.',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.message?.includes('model')) {
throw new AIProviderError(
`Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`,
AIErrorType.MODEL_NOT_FOUND,
error.status
);
}
// For other errors during validation, log but don't fail initialization
console.warn('Gemini connection validation warning:', error.message);
}
}
/**
* Validates model name format for Gemini models.
*
* @private
* @param modelName - Model name to validate
* @throws {AIProviderError} If model name format is invalid
*/
private validateModelName(modelName: string): void {
if (!modelName || typeof modelName !== 'string') {
throw new AIProviderError(
'Model name must be a non-empty string',
AIErrorType.INVALID_REQUEST
);
}
// Gemini model names follow specific patterns
const validPatterns = [
/^gemini-1\.5-pro(?:-latest|-vision)?$/, // e.g., gemini-1.5-pro, gemini-1.5-pro-latest
/^gemini-1\.5-flash(?:-latest)?$/, // e.g., gemini-1.5-flash, gemini-1.5-flash-latest
/^gemini-1\.0-pro(?:-latest|-vision)?$/, // e.g., gemini-1.0-pro, gemini-pro-vision
/^gemini-pro(?:-vision)?$/, // Legacy names: gemini-pro, gemini-pro-vision
/^models\/gemini-.+$/ // Full model path format
]; ];
const isValid = validPatterns.some(pattern => pattern.test(modelName));
if (!isValid) {
console.warn(`Model name '${modelName}' doesn't match expected Gemini naming patterns. This may cause API errors.`);
}
} }
/** protected override async sendValidationProbe(): Promise<void> {
* Gets the appropriate model instance for a request.
*
* @private
* @param params - Completion parameters
* @returns GenerativeModel instance
*/
private getModelForRequest(params: CompletionParams): GenerativeModel {
if (!this.client) { if (!this.client) {
throw new Error('Client not initialized'); throw new Error('Client not initialized');
} }
await this.client.models.generateContent({
// Use default model if no specific model requested model: this.defaultModel,
if (!params.model || params.model === this.defaultModel) { contents: [{ role: 'user', parts: [{ text: VALIDATION_PROMPT }] }],
return this.model!; config: { maxOutputTokens: 1 }
}
// Create new model instance for different model
return this.client.getGenerativeModel({
model: params.model,
safetySettings: this.safetySettings,
generationConfig: this.buildGenerationConfig(params)
}); });
} }
/** protected override providerErrorMessages(): Partial<Record<number, string>> {
* Converts generic messages to Gemini's conversation format. return {
* 401: 'Authentication failed with Gemini API. Please check your API key and permissions.',
* Gemini handles system messages as separate system instructions 403: 'Authentication failed with Gemini API. Please check your API key and permissions.',
* rather than part of the conversation flow. 404: 'Gemini API endpoint not found. Please check the model name and API version.',
* 429: 'Rate limit exceeded. Please reduce request frequency.',
* @private 500: 'Gemini service temporarily unavailable. Please try again in a few moments.',
* @param messages - Input messages array 502: 'Gemini service temporarily unavailable. Please try again in a few moments.',
* @returns Processed messages with system instruction separated 503: 'Gemini service temporarily unavailable. Please try again in a few moments.'
*/ };
}
protected override mapProviderError(error: any): AIProviderError | null {
if (!error) return null;
const message: string = error.message || '';
const status = error.status || error.statusCode;
if (message.includes('API key')) {
return new AIProviderError(
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
AIErrorType.AUTHENTICATION, status, error
);
}
if (message.includes('quota') || message.includes('limit exceeded')) {
return new AIProviderError(
'API quota exceeded. Please check your Google Cloud quotas and billing.',
AIErrorType.RATE_LIMIT, status, error
);
}
if (message.includes('model not found') || message.includes('invalid model')) {
return new AIProviderError(
'Model not found. The specified Gemini model may not exist or be available to your API key.',
AIErrorType.MODEL_NOT_FOUND, status, error
);
}
if (message.includes('safety') || message.includes('blocked')) {
return new AIProviderError(
'Content blocked by safety filters. Please modify your input or adjust safety settings.',
AIErrorType.INVALID_REQUEST, status, error
);
}
if (message.includes('length') || message.includes('token')) {
return new AIProviderError(
'Input too long or exceeds token limits. Please reduce input size or max tokens.',
AIErrorType.INVALID_REQUEST, status, error
);
}
return null;
}
private convertMessages(messages: AIMessage[]): ProcessedMessages { private convertMessages(messages: AIMessage[]): ProcessedMessages {
let systemInstruction: string | undefined; let systemInstruction: string | undefined;
const contents: Content[] = []; const contents: Content[] = [];
for (const message of messages) { for (const message of messages) {
if (message.role === 'system') { if (message.role === 'system') {
// Combine multiple system messages systemInstruction = systemInstruction
systemInstruction = systemInstruction
? `${systemInstruction}\n\n${message.content}` ? `${systemInstruction}\n\n${message.content}`
: message.content; : message.content;
} else { } else {
@@ -543,59 +295,61 @@ export class GeminiProvider extends BaseAIProvider {
return { systemInstruction, contents }; return { systemInstruction, contents };
} }
/** private buildConfig(
* Builds generation configuration from completion parameters. params: CompletionParams,
* systemInstruction?: string
* @private ): GenerateContentConfig {
* @param params - Completion parameters const defaults = this.defaultGenerationConfig;
* @returns Gemini generation configuration
*/ const config: GenerateContentConfig = {
private buildGenerationConfig(params: CompletionParams): GenerationConfig { temperature: params.temperature ?? defaults?.temperature ?? DEFAULT_TEMPERATURE,
return { maxOutputTokens: params.maxTokens ?? defaults?.maxOutputTokens ?? DEFAULT_MAX_TOKENS
temperature: params.temperature ?? this.generationConfig?.temperature ?? 0.7,
topP: params.topP ?? this.generationConfig?.topP,
topK: this.generationConfig?.topK,
maxOutputTokens: params.maxTokens ?? this.generationConfig?.maxOutputTokens ?? 1000,
stopSequences: params.stopSequences ?? this.generationConfig?.stopSequences
}; };
const topP = params.topP ?? defaults?.topP;
if (topP !== undefined) config.topP = topP;
if (defaults?.topK !== undefined) config.topK = defaults.topK;
const stopSequences = params.stopSequences ?? defaults?.stopSequences;
if (stopSequences) config.stopSequences = stopSequences;
if (systemInstruction) config.systemInstruction = systemInstruction;
if (this.safetySettings) config.safetySettings = this.safetySettings;
return config;
} }
/** private async *processStreamChunks(
* Processes streaming response chunks from Gemini API. stream: AsyncGenerator<GenerateContentResponse>
* ): AsyncIterable<CompletionChunk> {
* @private const requestId = `gemini-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
* @param stream - Gemini streaming response let lastUsage: GenerateContentResponse['usageMetadata'] | undefined;
* @returns AsyncIterable of formatted completion chunks
*/
private async *processStreamChunks(stream: any): AsyncIterable<CompletionChunk> {
let fullText = '';
const requestId = `gemini-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try { try {
// Process streaming chunks for await (const chunk of stream) {
for await (const chunk of stream.stream) { if (chunk.usageMetadata) {
const chunkText = chunk.text(); lastUsage = chunk.usageMetadata;
fullText += chunkText; }
yield { const text = chunk.text;
content: chunkText, if (text) {
isComplete: false, yield {
id: requestId content: text,
}; isComplete: false,
id: requestId
};
}
} }
// Final chunk with usage information
const finalResponse = await stream.response;
const usageMetadata = finalResponse.usageMetadata;
yield { yield {
content: '', content: '',
isComplete: true, isComplete: true,
id: requestId, id: requestId,
usage: { usage: {
promptTokens: usageMetadata?.promptTokenCount || 0, promptTokens: lastUsage?.promptTokenCount || 0,
completionTokens: usageMetadata?.candidatesTokenCount || 0, completionTokens: lastUsage?.candidatesTokenCount || 0,
totalTokens: usageMetadata?.totalTokenCount || 0 totalTokens: lastUsage?.totalTokenCount || 0
} }
}; };
} catch (error) { } catch (error) {
@@ -608,38 +362,11 @@ export class GeminiProvider extends BaseAIProvider {
} }
} }
/** private formatCompletionResponse(
* Formats Gemini's response to our standard interface. response: GenerateContentResponse,
* model: string
* @private ): CompletionResponse<string> {
* @param response - Raw Gemini API response const text = response.text;
* @param model - Model used for generation
* @returns Formatted completion response
* @throws {AIProviderError} If response format is unexpected
*/
private formatCompletionResponse(response: any, model: string): CompletionResponse<string> {
// Handle multiple text parts in the response
const candidate = response.candidates?.[0];
if (!candidate) {
throw new AIProviderError(
'No candidates found in Gemini response',
AIErrorType.UNKNOWN
);
}
const content = candidate.content;
if (!content || !content.parts) {
throw new AIProviderError(
'No content found in Gemini response',
AIErrorType.UNKNOWN
);
}
// Combine all text parts
const text = content.parts
.filter((part: any) => part.text)
.map((part: any) => part.text)
.join('');
if (!text) { if (!text) {
throw new AIProviderError( throw new AIProviderError(
@@ -648,9 +375,11 @@ export class GeminiProvider extends BaseAIProvider {
); );
} }
const candidate = response.candidates?.[0];
return { return {
content: text, content: text,
model: model, model,
usage: { usage: {
promptTokens: response.usageMetadata?.promptTokenCount || 0, promptTokens: response.usageMetadata?.promptTokenCount || 0,
completionTokens: response.usageMetadata?.candidatesTokenCount || 0, completionTokens: response.usageMetadata?.candidatesTokenCount || 0,
@@ -658,147 +387,11 @@ export class GeminiProvider extends BaseAIProvider {
}, },
id: `gemini-${Date.now()}`, id: `gemini-${Date.now()}`,
metadata: { metadata: {
finishReason: candidate.finishReason, finishReason: candidate?.finishReason,
safetyRatings: candidate.safetyRatings, safetyRatings: candidate?.safetyRatings,
citationMetadata: candidate.citationMetadata citationMetadata: candidate?.citationMetadata
} }
}; };
} }
/** }
* Handles and transforms Gemini-specific errors.
*
* This method maps Gemini's error responses to our standardized
* error format, providing helpful context and actionable suggestions.
*
* @private
* @param error - Original error from Gemini API
* @returns Normalized AIProviderError
*/
private handleGeminiError(error: any): AIProviderError {
if (error instanceof AIProviderError) {
return error;
}
const message = error.message || 'Unknown Gemini API error';
const status = error.status || error.statusCode;
// Map Gemini-specific error patterns
if (message.includes('API key')) {
return new AIProviderError(
'Invalid Google API key. Please verify your API key from https://aistudio.google.com/app/apikey',
AIErrorType.AUTHENTICATION,
status,
error
);
}
if (message.includes('quota') || message.includes('limit exceeded')) {
return new AIProviderError(
'API quota exceeded. Please check your Google Cloud quotas and billing.',
AIErrorType.RATE_LIMIT,
status,
error
);
}
if (message.includes('model not found') || message.includes('invalid model')) {
return new AIProviderError(
'Model not found. The specified Gemini model may not exist or be available to your API key.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
}
if (message.includes('safety') || message.includes('blocked')) {
return new AIProviderError(
'Content blocked by safety filters. Please modify your input or adjust safety settings.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('length') || message.includes('token')) {
return new AIProviderError(
'Input too long or exceeds token limits. Please reduce input size or max tokens.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('timeout')) {
return new AIProviderError(
'Request timed out. Gemini may be experiencing high load.',
AIErrorType.TIMEOUT,
status,
error
);
}
if (message.includes('network') || message.includes('connection')) {
return new AIProviderError(
'Network error connecting to Gemini servers.',
AIErrorType.NETWORK,
status,
error
);
}
// Handle HTTP status codes
switch (status) {
case 400:
return new AIProviderError(
`Invalid request to Gemini API: ${message}`,
AIErrorType.INVALID_REQUEST,
status,
error
);
case 401:
case 403:
return new AIProviderError(
'Authentication failed with Gemini API. Please check your API key and permissions.',
AIErrorType.AUTHENTICATION,
status,
error
);
case 404:
return new AIProviderError(
'Gemini API endpoint not found. Please check the model name and API version.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
case 429:
return new AIProviderError(
'Rate limit exceeded. Please reduce request frequency.',
AIErrorType.RATE_LIMIT,
status,
error
);
case 500:
case 502:
case 503:
return new AIProviderError(
'Gemini service temporarily unavailable. Please try again in a few moments.',
AIErrorType.NETWORK,
status,
error
);
default:
return new AIProviderError(
`Gemini API error: ${message}`,
AIErrorType.UNKNOWN,
status,
error
);
}
}
}

View File

@@ -35,6 +35,12 @@ import type {
} from '../types/index.js'; } from '../types/index.js';
import { BaseAIProvider } from './base.js'; import { BaseAIProvider } from './base.js';
import { AIProviderError, AIErrorType } from '../types/index.js'; import { AIProviderError, AIErrorType } from '../types/index.js';
import {
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
VALIDATION_PROMPT
} from '../constants.js';
// ============================================================================ // ============================================================================
// TYPES AND INTERFACES // TYPES AND INTERFACES
@@ -173,7 +179,7 @@ export class OpenAIProvider extends BaseAIProvider {
super(config); super(config);
// Set OpenAI-specific defaults // Set OpenAI-specific defaults
this.defaultModel = config.defaultModel || 'gpt-4o'; this.defaultModel = config.defaultModel || DEFAULT_MODELS.openai;
this.organization = config.organization; this.organization = config.organization;
this.project = config.project; this.project = config.project;
@@ -249,19 +255,9 @@ export class OpenAIProvider extends BaseAIProvider {
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const requestParams = this.buildRequestParams(params, false);
// Build optimized request parameters const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
const requestParams = this.buildRequestParams(params, false); return this.formatCompletionResponse(response);
// Make API request - explicitly type as ChatCompletion for non-streaming
const response = await this.client.chat.completions.create(requestParams) as OpenAI.Chat.Completions.ChatCompletion;
// Format and return response
return this.formatCompletionResponse(response);
} catch (error) {
throw this.handleOpenAIError(error as Error);
}
} }
/** /**
@@ -283,19 +279,9 @@ export class OpenAIProvider extends BaseAIProvider {
throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST); throw new AIProviderError('OpenAI client not initialized', AIErrorType.INVALID_REQUEST);
} }
try { const requestParams = this.buildRequestParams(params, true);
// Build streaming request parameters const stream = this.client.chat.completions.create(requestParams);
const requestParams = this.buildRequestParams(params, true); yield* this.processStreamChunks(stream);
// Create streaming request
const stream = this.client.chat.completions.create(requestParams);
// Process stream chunks
yield* this.processStreamChunks(stream);
} catch (error) {
throw this.handleOpenAIError(error as Error);
}
} }
// ======================================================================== // ========================================================================
@@ -340,87 +326,82 @@ export class OpenAIProvider extends BaseAIProvider {
// PRIVATE UTILITY METHODS // PRIVATE UTILITY METHODS
// ======================================================================== // ========================================================================
/** protected override getModelNamePatterns(): RegExp[] {
* Validates the connection by making a minimal test request. return [
* /^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/,
* @private /^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/,
* @throws {AIProviderError} If connection validation fails /^gpt-3\.5-turbo(?:-\d{4})?$/,
*/ /^gpt-4-\d{4}-preview$/,
private async validateConnection(): Promise<void> { /^text-davinci-\d{3}$/,
/^ft:.+$/
];
}
protected override async sendValidationProbe(): Promise<void> {
if (!this.client) { if (!this.client) {
throw new Error('Client not initialized'); throw new Error('Client not initialized');
} }
await this.client.chat.completions.create({
try { model: this.defaultModel,
// Make minimal request to test connection and permissions messages: [{ role: 'user', content: VALIDATION_PROMPT }],
await this.client.chat.completions.create({ max_tokens: 1
model: this.defaultModel, });
messages: [{ role: 'user', content: 'Hi' }],
max_tokens: 1
});
} catch (error: any) {
// Handle specific validation errors
if (error.status === 401) {
throw new AIProviderError(
'Invalid OpenAI API key. Please verify your API key from https://platform.openai.com/api-keys',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.status === 403) {
throw new AIProviderError(
'Access forbidden. Your API key may not have permission for this model or your account may have insufficient credits.',
AIErrorType.AUTHENTICATION,
error.status
);
}
if (error.status === 404 && error.message?.includes('model')) {
throw new AIProviderError(
`Model '${this.defaultModel}' is not available. Please check the model name or your API access level.`,
AIErrorType.MODEL_NOT_FOUND,
error.status
);
}
// For other errors during validation, log but don't fail initialization
// They might be temporary network issues
console.warn('OpenAI connection validation warning:', error.message);
}
} }
/** protected override providerErrorMessages(): Partial<Record<number, string>> {
* Validates model name format for OpenAI models. return {
* 401: 'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
* @private 403: 'Access forbidden. Your API key may not have permission for this model or feature.',
* @param modelName - Model name to validate 404: 'Model not found. The specified OpenAI model may not exist or be available to your account.',
* @throws {AIProviderError} If model name format is invalid 429: 'Too many requests. Please slow down and try again.',
*/ 500: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
private validateModelName(modelName: string): void { 502: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
if (!modelName || typeof modelName !== 'string') { 503: 'OpenAI service temporarily unavailable. Please try again in a few moments.',
throw new AIProviderError( 504: 'OpenAI service temporarily unavailable. Please try again in a few moments.'
'Model name must be a non-empty string', };
AIErrorType.INVALID_REQUEST }
protected override mapProviderError(error: any): AIProviderError | null {
if (!error) return null;
const message: string = error.message || '';
const status = error.status || error.statusCode;
if (status === 400) {
if (message.includes('max_tokens')) {
return new AIProviderError(
'Max tokens value exceeds model limit. Please reduce max_tokens or use a model with higher limits.',
AIErrorType.INVALID_REQUEST, status, error
);
}
if (message.includes('model')) {
return new AIProviderError(
'Invalid model specified. Please check model name and availability.',
AIErrorType.MODEL_NOT_FOUND, status, error
);
}
if (message.includes('messages')) {
return new AIProviderError(
'Invalid message format. Please check message structure and content.',
AIErrorType.INVALID_REQUEST, status, error
);
}
}
if (status === 403 && (message.includes('billing') || message.includes('quota'))) {
return new AIProviderError(
'Insufficient quota or billing issue. Please check your OpenAI account billing and usage limits.',
AIErrorType.AUTHENTICATION, status, error
); );
} }
// OpenAI model names follow specific patterns if (status === 429 && message.includes('quota')) {
const validPatterns = [ return new AIProviderError(
/^gpt-4o(?:-mini)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4o, gpt-4o-mini, gpt-4o-2024-11-20 'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
/^gpt-4(?:-turbo)?(?:-\d{4}-\d{2}-\d{2})?$/, // e.g., gpt-4, gpt-4-turbo, gpt-4-turbo-2024-04-09 AIErrorType.RATE_LIMIT, status, error
/^gpt-3\.5-turbo(?:-\d{4})?$/, // e.g., gpt-3.5-turbo, gpt-3.5-turbo-0125 );
/^gpt-4-\d{4}-preview$/, // e.g., gpt-4-0125-preview
/^text-davinci-\d{3}$/, // Legacy models
/^ft:.+$/ // Fine-tuned models
];
const isValid = validPatterns.some(pattern => pattern.test(modelName));
if (!isValid) {
console.warn(`Model name '${modelName}' doesn't match expected OpenAI naming patterns. This may cause API errors.`);
} }
return null;
} }
/** /**
@@ -435,8 +416,8 @@ export class OpenAIProvider extends BaseAIProvider {
return { return {
model: params.model || this.defaultModel, model: params.model || this.defaultModel,
messages: this.convertMessages(params.messages), messages: this.convertMessages(params.messages),
max_tokens: params.maxTokens || 1000, max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? 0.7, temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP, top_p: params.topP,
stop: params.stopSequences, stop: params.stopSequences,
stream, stream,
@@ -563,151 +544,4 @@ export class OpenAIProvider extends BaseAIProvider {
}; };
} }
/** }
* Handles and transforms OpenAI-specific errors.
*
* This method maps OpenAI's error responses to our standardized
* error format, providing helpful context and actionable suggestions.
*
* @private
* @param error - Original error from OpenAI API
* @returns Normalized AIProviderError
*/
private handleOpenAIError(error: any): AIProviderError {
if (error instanceof AIProviderError) {
return error;
}
const message = error.message || 'Unknown OpenAI API error';
const status = error.status || error.statusCode;
// Map OpenAI-specific error codes and types
switch (status) {
case 400:
if (message.includes('max_tokens')) {
return new AIProviderError(
'Max tokens value exceeds model limit. Please reduce max_tokens or use a model with higher limits.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
if (message.includes('model')) {
return new AIProviderError(
'Invalid model specified. Please check model name and availability.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
}
if (message.includes('messages')) {
return new AIProviderError(
'Invalid message format. Please check message structure and content.',
AIErrorType.INVALID_REQUEST,
status,
error
);
}
return new AIProviderError(
`Invalid request: ${message}`,
AIErrorType.INVALID_REQUEST,
status,
error
);
case 401:
return new AIProviderError(
'Authentication failed. Please verify your OpenAI API key from https://platform.openai.com/api-keys',
AIErrorType.AUTHENTICATION,
status,
error
);
case 403:
if (message.includes('billing') || message.includes('quota')) {
return new AIProviderError(
'Insufficient quota or billing issue. Please check your OpenAI account billing and usage limits.',
AIErrorType.AUTHENTICATION,
status,
error
);
}
return new AIProviderError(
'Access forbidden. Your API key may not have permission for this model or feature.',
AIErrorType.AUTHENTICATION,
status,
error
);
case 404:
return new AIProviderError(
'Model not found. The specified OpenAI model may not exist or be available to your account.',
AIErrorType.MODEL_NOT_FOUND,
status,
error
);
case 429:
if (message.includes('rate')) {
return new AIProviderError(
'Rate limit exceeded. Please reduce request frequency and implement exponential backoff.',
AIErrorType.RATE_LIMIT,
status,
error
);
}
if (message.includes('quota')) {
return new AIProviderError(
'Usage quota exceeded. Please check your OpenAI usage limits and billing.',
AIErrorType.RATE_LIMIT,
status,
error
);
}
return new AIProviderError(
'Too many requests. Please slow down and try again.',
AIErrorType.RATE_LIMIT,
status,
error
);
case 500:
case 502:
case 503:
case 504:
return new AIProviderError(
'OpenAI service temporarily unavailable. Please try again in a few moments.',
AIErrorType.NETWORK,
status,
error
);
default:
// Handle timeout and network errors
if (message.includes('timeout') || error.code === 'ETIMEDOUT') {
return new AIProviderError(
'Request timed out. OpenAI may be experiencing high load.',
AIErrorType.TIMEOUT,
status,
error
);
}
if (message.includes('network') || error.code === 'ECONNREFUSED') {
return new AIProviderError(
'Network error connecting to OpenAI servers.',
AIErrorType.NETWORK,
status,
error
);
}
return new AIProviderError(
`OpenAI API error: ${message}`,
AIErrorType.UNKNOWN,
status,
error
);
}
}
}

View File

@@ -0,0 +1,59 @@
import { AIProviderError, AIErrorType } from '../types/index.js';
import { DEFAULT_TIMEOUT_MS } from '../constants.js';
export interface OpenWebUIHttpOptions {
baseUrl: string;
apiKey?: string;
timeout?: number;
dangerouslyAllowInsecureConnections?: boolean;
}
/**
* Thin HTTP client shared by OpenWebUI strategies.
* Handles auth header, request body serialization, and timeout-to-AIProviderError translation.
*/
export class OpenWebUIHttpClient {
readonly baseUrl: string;
private readonly apiKey: string | undefined;
private readonly timeout: number;
private readonly dangerouslyAllowInsecureConnections: boolean;
constructor(options: OpenWebUIHttpOptions) {
this.baseUrl = options.baseUrl;
this.apiKey = options.apiKey;
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
this.dangerouslyAllowInsecureConnections = options.dangerouslyAllowInsecureConnections ?? true;
}
async request(path: string, method: string, body?: unknown): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'simple-ai-provider/2.0.0'
};
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}
const requestOptions: RequestInit = {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(this.timeout)
};
try {
return await fetch(`${this.baseUrl}${path}`, requestOptions);
} catch (error: any) {
if (error.name === 'AbortError') {
throw new AIProviderError(
'Request timed out',
AIErrorType.TIMEOUT,
undefined,
error
);
}
throw error;
}
}
}

View File

@@ -0,0 +1,306 @@
import type {
AIMessage,
CompletionChunk,
CompletionParams,
CompletionResponse
} from '../types/index.js';
import { AIProviderError, AIErrorType } from '../types/index.js';
import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants.js';
import type { OpenWebUIHttpClient } from './openwebui-http.js';
import type {
OllamaGenerateResponse,
OpenWebUIChatResponse,
OpenWebUIModelsResponse,
OpenWebUIStreamChunk
} from './openwebui-types.js';
/**
* Strategy interface for OpenWebUI's two backend modes.
* Selected at construction based on `useOllamaProxy`.
*/
export interface OpenWebUIStrategy {
validateConnection(): Promise<void>;
complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>>;
stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk>;
}
// ============================================================================
// Chat strategy — OpenAI-compatible /api/chat/completions endpoint
// ============================================================================
export class OpenWebUIChatStrategy implements OpenWebUIStrategy {
constructor(private readonly http: OpenWebUIHttpClient) {}
async validateConnection(): Promise<void> {
const response = await this.http.request('/api/models', 'GET');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json() as OpenWebUIModelsResponse;
if (!data.data || !Array.isArray(data.data)) {
throw new Error('Invalid models response format');
}
}
async complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>> {
const response = await this.http.request('/api/chat/completions', 'POST', {
model: params.model || defaultModel,
messages: convertMessages(params.messages),
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream: false
});
const data = await response.json() as OpenWebUIChatResponse;
return formatChatResponse(data);
}
async *stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk> {
const response = await this.http.request('/api/chat/completions', 'POST', {
model: params.model || defaultModel,
messages: convertMessages(params.messages),
max_tokens: params.maxTokens || DEFAULT_MAX_TOKENS,
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
stop: params.stopSequences,
stream: true
});
if (!response.body) {
throw new Error('No response body for streaming');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let messageId = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed.startsWith('data: ')) continue;
const data = trimmed.slice(6);
if (data === '[DONE]') return;
let chunk: OpenWebUIStreamChunk;
try {
chunk = JSON.parse(data) as OpenWebUIStreamChunk;
} catch (parseError) {
console.warn('Failed to parse streaming chunk:', parseError);
continue;
}
if (chunk.id && !messageId) {
messageId = chunk.id;
}
const delta = chunk.choices[0]?.delta;
if (delta?.content) {
yield {
content: delta.content,
isComplete: false,
id: messageId || chunk.id
};
}
if (chunk.choices[0]?.finish_reason) {
yield {
content: '',
isComplete: true,
id: messageId || chunk.id,
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
};
return;
}
}
}
} finally {
reader.releaseLock();
}
}
}
// ============================================================================
// Ollama strategy — direct /ollama/api/generate endpoint
// ============================================================================
export class OpenWebUIOllamaStrategy implements OpenWebUIStrategy {
constructor(private readonly http: OpenWebUIHttpClient) {}
async validateConnection(): Promise<void> {
const response = await this.http.request('/ollama/api/tags', 'GET');
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
}
async complete(params: CompletionParams, defaultModel: string): Promise<CompletionResponse<string>> {
const response = await this.http.request('/ollama/api/generate', 'POST', {
model: params.model || defaultModel,
prompt: convertMessagesToPrompt(params.messages),
stream: false,
options: {
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
stop: params.stopSequences
}
});
const data = await response.json() as OllamaGenerateResponse;
return formatOllamaResponse(data);
}
async *stream(params: CompletionParams, defaultModel: string): AsyncIterable<CompletionChunk> {
const response = await this.http.request('/ollama/api/generate', 'POST', {
model: params.model || defaultModel,
prompt: convertMessagesToPrompt(params.messages),
stream: true,
options: {
temperature: params.temperature ?? DEFAULT_TEMPERATURE,
top_p: params.topP,
num_predict: params.maxTokens || DEFAULT_MAX_TOKENS,
stop: params.stopSequences
}
});
if (!response.body) {
throw new Error('No response body for streaming');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const messageId = `ollama-${Date.now()}`;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let chunk: OllamaGenerateResponse;
try {
chunk = JSON.parse(trimmed) as OllamaGenerateResponse;
} catch (parseError) {
console.warn('Failed to parse Ollama streaming chunk:', parseError);
continue;
}
if (chunk.response) {
yield {
content: chunk.response,
isComplete: false,
id: messageId
};
}
if (chunk.done) {
yield {
content: '',
isComplete: true,
id: messageId,
usage: {
promptTokens: chunk.prompt_eval_count || 0,
completionTokens: chunk.eval_count || 0,
totalTokens: (chunk.prompt_eval_count || 0) + (chunk.eval_count || 0)
}
};
return;
}
}
}
} finally {
reader.releaseLock();
}
}
}
// ============================================================================
// Shared message helpers
// ============================================================================
function convertMessages(messages: AIMessage[]): Array<{ role: string; content: string }> {
return messages.map(message => ({
role: message.role,
content: message.content
}));
}
function convertMessagesToPrompt(messages: AIMessage[]): string {
let prompt = '';
for (const message of messages) {
switch (message.role) {
case 'system':
prompt += `System: ${message.content}\n\n`;
break;
case 'user':
prompt += `Human: ${message.content}\n\n`;
break;
case 'assistant':
prompt += `Assistant: ${message.content}\n\n`;
break;
}
}
return prompt + 'Assistant: ';
}
function formatChatResponse(response: OpenWebUIChatResponse): CompletionResponse<string> {
const choice = response.choices[0];
if (!choice || !choice.message.content) {
throw new AIProviderError('No content found in OpenWebUI response', AIErrorType.UNKNOWN);
}
return {
content: choice.message.content,
model: response.model,
usage: {
promptTokens: response.usage?.prompt_tokens || 0,
completionTokens: response.usage?.completion_tokens || 0,
totalTokens: response.usage?.total_tokens || 0
},
id: response.id,
metadata: {
finishReason: choice.finish_reason,
created: response.created
}
};
}
function formatOllamaResponse(response: OllamaGenerateResponse): CompletionResponse<string> {
return {
content: response.response,
model: response.model,
usage: {
promptTokens: response.prompt_eval_count || 0,
completionTokens: response.eval_count || 0,
totalTokens: (response.prompt_eval_count || 0) + (response.eval_count || 0)
},
id: `ollama-${Date.now()}`,
metadata: {
created: new Date(response.created_at).getTime(),
totalDuration: response.total_duration,
loadDuration: response.load_duration,
promptEvalDuration: response.prompt_eval_duration,
evalDuration: response.eval_duration
}
};
}

View File

@@ -0,0 +1,55 @@
/**
* Wire-format response types for OpenWebUI's two backends.
*/
export interface OpenWebUIChatResponse {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
message: { role: string; content: string };
finish_reason: string | null;
}>;
usage?: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
export interface OpenWebUIStreamChunk {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
delta: { role?: string; content?: string };
finish_reason: string | null;
}>;
}
export interface OllamaGenerateResponse {
model: string;
created_at: string;
response: string;
done: boolean;
context?: number[];
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
eval_count?: number;
eval_duration?: number;
}
export interface OpenWebUIModelsResponse {
data: Array<{
id: string;
object: string;
created: number;
owned_by: string;
}>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,22 @@
/** /**
* Factory utilities for creating AI providers * Factory utilities for creating AI providers.
* Provides convenient methods for instantiating and configuring providers
*/ */
import type { AIProviderConfig } from '../types/index.js';
import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js'; import { ClaudeProvider, type ClaudeConfig } from '../providers/claude.js';
import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js'; import { OpenAIProvider, type OpenAIConfig } from '../providers/openai.js';
import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js'; import { GeminiProvider, type GeminiConfig } from '../providers/gemini.js';
import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js'; import { OpenWebUIProvider, type OpenWebUIConfig } from '../providers/openwebui.js';
import { BaseAIProvider } from '../providers/base.js'; import { BaseAIProvider } from '../providers/base.js';
/** export const PROVIDER_REGISTRY = {
* Supported AI provider types claude: ClaudeProvider,
*/ openai: OpenAIProvider,
export type ProviderType = 'claude' | 'openai' | 'gemini' | 'openwebui'; gemini: GeminiProvider,
openwebui: OpenWebUIProvider
} as const;
export type ProviderType = keyof typeof PROVIDER_REGISTRY;
/**
* Configuration map for different provider types
*/
export interface ProviderConfigMap { export interface ProviderConfigMap {
claude: ClaudeConfig; claude: ClaudeConfig;
openai: OpenAIConfig; openai: OpenAIConfig;
@@ -25,144 +24,38 @@ export interface ProviderConfigMap {
openwebui: OpenWebUIConfig; openwebui: OpenWebUIConfig;
} }
/**
* Factory function to create AI providers
* @param type - The type of provider to create
* @param config - Configuration for the provider
* @returns Configured AI provider instance
*/
export function createProvider<T extends ProviderType>( export function createProvider<T extends ProviderType>(
type: T, type: T,
config: ProviderConfigMap[T] config: ProviderConfigMap[T]
): BaseAIProvider { ): BaseAIProvider {
switch (type) { const ProviderClass = PROVIDER_REGISTRY[type];
case 'claude': if (!ProviderClass) {
return new ClaudeProvider(config as ClaudeConfig); throw new Error(`Unsupported provider type: ${type}`);
case 'openai':
return new OpenAIProvider(config as OpenAIConfig);
case 'gemini':
return new GeminiProvider(config as GeminiConfig);
case 'openwebui':
return new OpenWebUIProvider(config as OpenWebUIConfig);
default:
throw new Error(`Unsupported provider type: ${type}`);
} }
return new ProviderClass(config as any);
} }
/**
* Create a Claude provider with simplified configuration
* @param apiKey - Anthropic API key
* @param options - Optional additional configuration
* @returns Configured Claude provider instance
*/
export function createClaudeProvider( export function createClaudeProvider(
apiKey: string, apiKey: string,
options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {} options: Partial<Omit<ClaudeConfig, 'apiKey'>> = {}
): ClaudeProvider { ): ClaudeProvider {
return new ClaudeProvider({ return new ClaudeProvider({ apiKey, ...options });
apiKey,
...options
});
} }
/**
* Create an OpenAI provider with simplified configuration
* @param apiKey - OpenAI API key
* @param options - Optional additional configuration
* @returns Configured OpenAI provider instance
*/
export function createOpenAIProvider( export function createOpenAIProvider(
apiKey: string, apiKey: string,
options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {} options: Partial<Omit<OpenAIConfig, 'apiKey'>> = {}
): OpenAIProvider { ): OpenAIProvider {
return new OpenAIProvider({ return new OpenAIProvider({ apiKey, ...options });
apiKey,
...options
});
} }
/**
* Create a Gemini provider with simplified configuration
* @param apiKey - Google AI API key
* @param options - Optional additional configuration
* @returns Configured Gemini provider instance
*/
export function createGeminiProvider( export function createGeminiProvider(
apiKey: string, apiKey: string,
options: Partial<Omit<GeminiConfig, 'apiKey'>> = {} options: Partial<Omit<GeminiConfig, 'apiKey'>> = {}
): GeminiProvider { ): GeminiProvider {
return new GeminiProvider({ return new GeminiProvider({ apiKey, ...options });
apiKey,
...options
});
} }
/**
* Create an OpenWebUI provider instance
*/
export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider { export function createOpenWebUIProvider(config: OpenWebUIConfig): OpenWebUIProvider {
return new OpenWebUIProvider(config); return new OpenWebUIProvider(config);
} }
/**
* Provider registry for dynamic provider creation
*/
export class ProviderRegistry {
private static providers = new Map<string, new (config: AIProviderConfig) => BaseAIProvider>();
/**
* Register a new provider type
* @param name - Name of the provider
* @param providerClass - Provider class constructor
*/
static register(name: string, providerClass: new (config: AIProviderConfig) => BaseAIProvider): void {
this.providers.set(name.toLowerCase(), providerClass);
}
/**
* Create a provider by name
* @param name - Name of the provider
* @param config - Configuration for the provider
* @returns Provider instance
*/
static create(name: string, config: AIProviderConfig): BaseAIProvider {
const ProviderClass = this.providers.get(name.toLowerCase());
if (!ProviderClass) {
throw new Error(`Provider '${name}' is not registered`);
}
return new ProviderClass(config);
}
/**
* Get list of registered provider names
* @returns Array of registered provider names
*/
static getRegisteredProviders(): string[] {
return Array.from(this.providers.keys());
}
/**
* Check if a provider is registered
* @param name - Name of the provider
* @returns True if provider is registered
*/
static isRegistered(name: string): boolean {
return this.providers.has(name.toLowerCase());
}
}
// Pre-register built-in providers
ProviderRegistry.register('claude', ClaudeProvider);
ProviderRegistry.register('openai', OpenAIProvider);
ProviderRegistry.register('gemini', GeminiProvider);
ProviderRegistry.register('openwebui', OpenWebUIProvider);
/**
* Registry of all available providers
*/
export const PROVIDER_REGISTRY = {
claude: ClaudeProvider,
openai: OpenAIProvider,
gemini: GeminiProvider,
openwebui: OpenWebUIProvider
} as const;