Compare commits
3 Commits
dev
...
mobile/jan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6a71828da | ||
|
|
ef1e990d40 | ||
|
|
79e915d97d |
45
extensions/jan-provider-extension/package.json
Normal file
45
extensions/jan-provider-extension/package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@janhq/jan-provider-extension",
|
||||
"productName": "Jan Provider",
|
||||
"version": "1.0.0",
|
||||
"description": "Provides remote model inference through Jan API",
|
||||
"main": "dist/index.js",
|
||||
"author": "Jan <service@jan.ai>",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"build": "rolldown -c rolldown.config.mjs",
|
||||
"build:publish": "rimraf *.tgz --glob || true && yarn build && npm pack && cpx *.tgz ../../pre-install",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./main": "./dist/module.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpx": "1.5.0",
|
||||
"rimraf": "6.0.1",
|
||||
"rolldown": "1.0.0-beta.1",
|
||||
"ts-loader": "^9.5.0",
|
||||
"typescript": "5.9.2",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@janhq/core": "../../core/package.tgz",
|
||||
"@tauri-apps/plugin-http": "2.5.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist/*",
|
||||
"package.json",
|
||||
"README.md"
|
||||
],
|
||||
"bundleDependencies": [],
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
},
|
||||
"packageManager": "yarn@4.5.3"
|
||||
}
|
||||
14
extensions/jan-provider-extension/rolldown.config.mjs
Normal file
14
extensions/jan-provider-extension/rolldown.config.mjs
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'rolldown'
|
||||
|
||||
export default defineConfig({
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
format: 'esm',
|
||||
file: 'dist/index.js',
|
||||
inlineDynamicImports: true, // Required for dynamic import of @tauri-apps/plugin-http
|
||||
},
|
||||
platform: 'browser',
|
||||
define: {
|
||||
JAN_API_BASE: JSON.stringify(process.env.JAN_API_BASE || 'https://api-dev.jan.ai/v1'),
|
||||
},
|
||||
})
|
||||
@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Mobile Authentication Tests
|
||||
* Verifies Jan Provider can authenticate and fetch models on mobile
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
|
||||
describe('Jan Provider Mobile Authentication', () => {
|
||||
const mockFetch = vi.fn()
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mobile environment
|
||||
;(globalThis as any).IS_WEB_APP = false
|
||||
vi.clearAllMocks()
|
||||
global.fetch = mockFetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
delete (globalThis as any).IS_WEB_APP
|
||||
})
|
||||
|
||||
describe('Guest Login Flow', () => {
|
||||
it('should perform guest login and get access token', async () => {
|
||||
// Mock guest login response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'guest-token-123',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.jan.ai/v1/auth/guest-login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
const data = await response.json()
|
||||
expect(data.access_token).toBe('guest-token-123')
|
||||
expect(data.expires_in).toBe(3600)
|
||||
})
|
||||
|
||||
it('should handle guest login failure', async () => {
|
||||
// Mock failed guest login
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.jan.ai/v1/auth/guest-login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(response.status).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authenticated API Requests', () => {
|
||||
it('should fetch models with guest token', async () => {
|
||||
const mockModels = {
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'gpt-4o-mini',
|
||||
object: 'model',
|
||||
owned_by: 'openai',
|
||||
},
|
||||
{
|
||||
id: 'claude-3-5-sonnet-20241022',
|
||||
object: 'model',
|
||||
owned_by: 'anthropic',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Mock models response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: async () => mockModels,
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.jan.ai/v1/conv/models', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer guest-token-123',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.ok).toBe(true)
|
||||
const data = await response.json()
|
||||
expect(data.data).toHaveLength(2)
|
||||
expect(data.data[0].id).toBe('gpt-4o-mini')
|
||||
expect(data.data[1].id).toBe('claude-3-5-sonnet-20241022')
|
||||
})
|
||||
|
||||
it('should handle 401 unauthorized without token', async () => {
|
||||
// Mock 401 response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => JSON.stringify({
|
||||
code: '019947f0-eca1-7474-8ed2-09d6e5389b54',
|
||||
error: '',
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.jan.ai/v1/conv/models', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('should include Bearer token in Authorization header', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ object: 'list', data: [] }),
|
||||
})
|
||||
|
||||
await fetch('https://api.jan.ai/v1/conv/models', {
|
||||
headers: {
|
||||
'Authorization': 'Bearer guest-token-123',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://api.jan.ai/v1/conv/models',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer guest-token-123',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Token Caching and Renewal', () => {
|
||||
it('should cache token and reuse until expiry', () => {
|
||||
const now = Date.now()
|
||||
const expiresIn = 3600 // 1 hour
|
||||
const tokenExpiryTime = now + (expiresIn * 1000) - 60000 // 1 min buffer
|
||||
|
||||
// First call - should use cached token
|
||||
const shouldRenew1 = Date.now() >= tokenExpiryTime
|
||||
expect(shouldRenew1).toBe(false)
|
||||
|
||||
// Still within expiry - should use cached token
|
||||
const futureTime = now + 1800000 // 30 minutes later
|
||||
const shouldRenew2 = futureTime >= tokenExpiryTime
|
||||
expect(shouldRenew2).toBe(false)
|
||||
|
||||
// After expiry - should renew token
|
||||
const expiredTime = now + 3600000 // 1 hour later
|
||||
const shouldRenew3 = expiredTime >= tokenExpiryTime
|
||||
expect(shouldRenew3).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Endpoint Configuration', () => {
|
||||
it('should use production API for mobile', () => {
|
||||
const apiBase = 'https://api.jan.ai/v1'
|
||||
|
||||
expect(apiBase).toBe('https://api.jan.ai/v1')
|
||||
expect(apiBase).not.toContain('api-dev')
|
||||
})
|
||||
|
||||
it('should construct correct endpoints', () => {
|
||||
const apiBase = 'https://api.jan.ai/v1'
|
||||
|
||||
const guestLoginEndpoint = `${apiBase}/auth/guest-login`
|
||||
const modelsEndpoint = `${apiBase}/conv/models`
|
||||
const chatEndpoint = `${apiBase}/conv/chat/completions`
|
||||
|
||||
expect(guestLoginEndpoint).toBe('https://api.jan.ai/v1/auth/guest-login')
|
||||
expect(modelsEndpoint).toBe('https://api.jan.ai/v1/conv/models')
|
||||
expect(chatEndpoint).toBe('https://api.jan.ai/v1/conv/chat/completions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Platform Detection', () => {
|
||||
it('should detect Tauri platform correctly', () => {
|
||||
const IS_WEB_APP = (globalThis as any).IS_WEB_APP
|
||||
const isTauri =
|
||||
typeof IS_WEB_APP === 'undefined' ||
|
||||
(IS_WEB_APP !== true && IS_WEB_APP !== 'true')
|
||||
|
||||
expect(isTauri).toBe(true)
|
||||
})
|
||||
|
||||
it('should not detect web as Tauri', () => {
|
||||
;(globalThis as any).IS_WEB_APP = true
|
||||
|
||||
const IS_WEB_APP = (globalThis as any).IS_WEB_APP
|
||||
const isTauri =
|
||||
typeof IS_WEB_APP === 'undefined' ||
|
||||
(IS_WEB_APP !== true && IS_WEB_APP !== 'true')
|
||||
|
||||
expect(isTauri).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle network errors gracefully', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
await expect(
|
||||
fetch('https://api.jan.ai/v1/conv/models')
|
||||
).rejects.toThrow('Network error')
|
||||
})
|
||||
|
||||
it('should handle malformed JSON responses', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new Error('Invalid JSON')
|
||||
},
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.jan.ai/v1/conv/models')
|
||||
await expect(response.json()).rejects.toThrow('Invalid JSON')
|
||||
})
|
||||
|
||||
it('should provide detailed error messages', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => 'Server error details',
|
||||
})
|
||||
|
||||
const response = await fetch('https://api.jan.ai/v1/conv/models')
|
||||
expect(response.ok).toBe(false)
|
||||
|
||||
const errorText = await response.text()
|
||||
expect(errorText).toBe('Server error details')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration: Full Authentication Flow', () => {
|
||||
const mockFetch = vi.fn()
|
||||
const originalFetch = global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
;(globalThis as any).IS_WEB_APP = false
|
||||
vi.clearAllMocks()
|
||||
global.fetch = mockFetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
delete (globalThis as any).IS_WEB_APP
|
||||
})
|
||||
|
||||
it('should complete full flow: guest login -> fetch models -> use models', async () => {
|
||||
// Step 1: Guest login
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'guest-token-abc',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
})
|
||||
|
||||
const loginResponse = await fetch('https://api.jan.ai/v1/auth/guest-login', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
expect(loginResponse.ok).toBe(true)
|
||||
const { access_token } = await loginResponse.json()
|
||||
|
||||
// Step 2: Fetch models with token
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
object: 'list',
|
||||
data: [
|
||||
{ id: 'gpt-4o-mini', object: 'model', owned_by: 'openai' },
|
||||
{ id: 'claude-3-5-sonnet-20241022', object: 'model', owned_by: 'anthropic' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
const modelsResponse = await fetch('https://api.jan.ai/v1/conv/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${access_token}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(modelsResponse.ok).toBe(true)
|
||||
const models = await modelsResponse.json()
|
||||
|
||||
// Step 3: Verify models can be used
|
||||
expect(models.data).toHaveLength(2)
|
||||
expect(models.data[0].id).toBe('gpt-4o-mini')
|
||||
expect(models.data[1].id).toBe('claude-3-5-sonnet-20241022')
|
||||
|
||||
// Verify provider info
|
||||
const janProvider = {
|
||||
provider: 'jan',
|
||||
models: models.data,
|
||||
active: true,
|
||||
}
|
||||
|
||||
expect(janProvider.provider).toBe('jan')
|
||||
expect(janProvider.models.length).toBeGreaterThan(0)
|
||||
expect(janProvider.active).toBe(true)
|
||||
})
|
||||
})
|
||||
237
extensions/jan-provider-extension/src/api.ts
Normal file
237
extensions/jan-provider-extension/src/api.ts
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Jan Provider API Client
|
||||
* Handles API requests to Jan backend for models and chat completions
|
||||
*/
|
||||
|
||||
import { makeAuthenticatedRequest, getAuthHeader } from './auth'
|
||||
import { janProviderStore } from './store'
|
||||
import type {
|
||||
JanModel,
|
||||
JanModelsResponse,
|
||||
JanChatCompletionRequest,
|
||||
JanChatCompletionResponse,
|
||||
JanChatCompletionChunk,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Get platform-appropriate fetch (Tauri fetch for mobile, native for web)
|
||||
*/
|
||||
async function getPlatformFetch(): Promise<typeof fetch> {
|
||||
const IS_WEB_APP = (globalThis as any).IS_WEB_APP
|
||||
const isTauri = typeof IS_WEB_APP === 'undefined' ||
|
||||
(IS_WEB_APP !== true && IS_WEB_APP !== 'true')
|
||||
|
||||
if (isTauri) {
|
||||
try {
|
||||
const httpPlugin = await import('@tauri-apps/plugin-http')
|
||||
return httpPlugin.fetch as typeof fetch
|
||||
} catch {
|
||||
return fetch
|
||||
}
|
||||
}
|
||||
|
||||
return fetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Jan API base URL (production only for mobile)
|
||||
*/
|
||||
function getApiBase(): string {
|
||||
if (typeof (globalThis as any).JAN_API_BASE !== 'undefined') {
|
||||
return (globalThis as any).JAN_API_BASE
|
||||
}
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.JAN_API_BASE) {
|
||||
return import.meta.env.JAN_API_BASE
|
||||
}
|
||||
|
||||
return 'https://api.jan.ai/v1'
|
||||
}
|
||||
|
||||
const TEMPORARY_CHAT_ID = 'temporary-chat'
|
||||
|
||||
/**
|
||||
* Get chat completion endpoint and payload configuration
|
||||
*/
|
||||
function getChatCompletionConfig(
|
||||
request: JanChatCompletionRequest,
|
||||
stream: boolean = false
|
||||
) {
|
||||
const endpoint = `${getApiBase()}/chat/completions`
|
||||
|
||||
const payload = {
|
||||
...request,
|
||||
stream,
|
||||
conversation_id: undefined,
|
||||
}
|
||||
|
||||
return { endpoint, payload, isTemporaryChat: request.conversation_id === TEMPORARY_CHAT_ID }
|
||||
}
|
||||
|
||||
export class JanApiClient {
|
||||
private static instance: JanApiClient
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): JanApiClient {
|
||||
if (!JanApiClient.instance) {
|
||||
JanApiClient.instance = new JanApiClient()
|
||||
}
|
||||
return JanApiClient.instance
|
||||
}
|
||||
|
||||
async getModels(): Promise<JanModel[]> {
|
||||
try {
|
||||
janProviderStore.setLoadingModels(true)
|
||||
janProviderStore.clearError()
|
||||
|
||||
const apiBase = getApiBase()
|
||||
const response = await makeAuthenticatedRequest<JanModelsResponse>(
|
||||
`${apiBase}/conv/models`
|
||||
)
|
||||
|
||||
const models = response.data || []
|
||||
janProviderStore.setModels(models)
|
||||
|
||||
return models
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to fetch models'
|
||||
janProviderStore.setError(errorMessage)
|
||||
janProviderStore.setLoadingModels(false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async createChatCompletion(
|
||||
request: JanChatCompletionRequest
|
||||
): Promise<JanChatCompletionResponse> {
|
||||
try {
|
||||
janProviderStore.clearError()
|
||||
|
||||
const { endpoint, payload } = getChatCompletionConfig(request, false)
|
||||
|
||||
return await makeAuthenticatedRequest<JanChatCompletionResponse>(
|
||||
endpoint,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to create chat completion'
|
||||
janProviderStore.setError(errorMessage)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async createStreamingChatCompletion(
|
||||
request: JanChatCompletionRequest,
|
||||
onChunk: (chunk: JanChatCompletionChunk) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: Error) => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
janProviderStore.clearError()
|
||||
|
||||
const authHeader = await getAuthHeader()
|
||||
const { endpoint, payload } = getChatCompletionConfig(request, true)
|
||||
|
||||
const platformFetch = await getPlatformFetch()
|
||||
const response = await platformFetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeader,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(
|
||||
`API request failed: ${response.status} ${response.statusText} - ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
try {
|
||||
let buffer = ''
|
||||
|
||||
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 trimmedLine = line.trim()
|
||||
if (trimmedLine.startsWith('data: ')) {
|
||||
const data = trimmedLine.slice(6).trim()
|
||||
|
||||
if (data === '[DONE]') {
|
||||
onComplete?.()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedChunk: JanChatCompletionChunk = JSON.parse(data)
|
||||
onChunk(parsedChunk)
|
||||
} catch (parseError) {
|
||||
console.warn(
|
||||
'Failed to parse SSE chunk:',
|
||||
parseError,
|
||||
'Data:',
|
||||
data
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.()
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error ? error : new Error('Unknown error occurred')
|
||||
janProviderStore.setError(err.message)
|
||||
onError?.(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
try {
|
||||
janProviderStore.setAuthenticated(true)
|
||||
await this.getModels()
|
||||
console.log('Jan API client initialized successfully')
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to initialize API client'
|
||||
janProviderStore.setError(errorMessage)
|
||||
throw error
|
||||
} finally {
|
||||
janProviderStore.setInitializing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const janApiClient = JanApiClient.getInstance()
|
||||
151
extensions/jan-provider-extension/src/auth.ts
Normal file
151
extensions/jan-provider-extension/src/auth.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Jan Auth Client for Mobile
|
||||
* Handles authentication using guest login for mobile platforms
|
||||
*/
|
||||
|
||||
interface AuthTokens {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
interface JanAuthService {
|
||||
getAuthHeader(): Promise<{ Authorization: string }>
|
||||
makeAuthenticatedRequest<T>(url: string, options?: RequestInit): Promise<T>
|
||||
initialize(): Promise<void>
|
||||
isAuthenticated(): boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
janAuthService?: JanAuthService
|
||||
}
|
||||
}
|
||||
|
||||
let guestAccessToken: string | null = null
|
||||
let guestTokenExpiry: number = 0
|
||||
|
||||
function getApiBase(): string {
|
||||
return 'https://api.jan.ai/v1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform guest login to obtain access token
|
||||
*/
|
||||
async function guestLogin(platformFetch: typeof fetch): Promise<AuthTokens> {
|
||||
const response = await platformFetch(`${getApiBase()}/auth/guest-login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Guest login failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json() as Promise<AuthTokens>
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure valid guest access token, refreshing if expired
|
||||
*/
|
||||
async function ensureGuestToken(platformFetch: typeof fetch): Promise<string> {
|
||||
if (guestAccessToken && Date.now() < guestTokenExpiry) {
|
||||
return guestAccessToken
|
||||
}
|
||||
|
||||
const tokens = await guestLogin(platformFetch)
|
||||
guestAccessToken = tokens.access_token
|
||||
guestTokenExpiry = Date.now() + (tokens.expires_in * 1000) - 60000
|
||||
|
||||
return guestAccessToken
|
||||
}
|
||||
|
||||
export function getAuthService(): JanAuthService | null {
|
||||
return window.janAuthService || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for auth service initialization (web platform)
|
||||
*/
|
||||
export async function waitForAuthService(
|
||||
maxWaitMs: number = 5000
|
||||
): Promise<JanAuthService | null> {
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const authService = getAuthService()
|
||||
if (authService) return authService
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-appropriate fetch (Tauri fetch for mobile, native for web)
|
||||
*/
|
||||
async function getPlatformFetch(): Promise<typeof fetch> {
|
||||
const IS_WEB_APP = (globalThis as any).IS_WEB_APP
|
||||
const isTauri = typeof IS_WEB_APP === 'undefined' ||
|
||||
(IS_WEB_APP !== true && IS_WEB_APP !== 'true')
|
||||
|
||||
if (isTauri) {
|
||||
try {
|
||||
const httpPlugin = await import('@tauri-apps/plugin-http')
|
||||
return httpPlugin.fetch as typeof fetch
|
||||
} catch (error) {
|
||||
console.warn('Tauri fetch unavailable, using native fetch')
|
||||
return fetch
|
||||
}
|
||||
}
|
||||
|
||||
return fetch
|
||||
}
|
||||
|
||||
/**
|
||||
* Make authenticated request to Jan API with automatic guest login
|
||||
*/
|
||||
export async function makeAuthenticatedRequest<T>(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const authService = await waitForAuthService()
|
||||
|
||||
if (authService) {
|
||||
return authService.makeAuthenticatedRequest<T>(url, options)
|
||||
}
|
||||
|
||||
const platformFetch = await getPlatformFetch()
|
||||
const accessToken = await ensureGuestToken(platformFetch)
|
||||
|
||||
const response = await platformFetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get authorization header with guest token
|
||||
*/
|
||||
export async function getAuthHeader(): Promise<{ Authorization?: string }> {
|
||||
const authService = await waitForAuthService()
|
||||
|
||||
if (authService) {
|
||||
return authService.getAuthHeader()
|
||||
}
|
||||
|
||||
const platformFetch = await getPlatformFetch()
|
||||
const accessToken = await ensureGuestToken(platformFetch)
|
||||
return { Authorization: `Bearer ${accessToken}` }
|
||||
}
|
||||
1
extensions/jan-provider-extension/src/index.ts
Normal file
1
extensions/jan-provider-extension/src/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './provider'
|
||||
459
extensions/jan-provider-extension/src/provider.ts
Normal file
459
extensions/jan-provider-extension/src/provider.ts
Normal file
@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Jan Provider Extension
|
||||
* Provides remote model inference through Jan API
|
||||
* Available on web and mobile platforms (disabled on desktop Tauri)
|
||||
*/
|
||||
|
||||
import {
|
||||
AIEngine,
|
||||
modelInfo,
|
||||
SessionInfo,
|
||||
UnloadResult,
|
||||
chatCompletionRequest,
|
||||
chatCompletion,
|
||||
chatCompletionChunk,
|
||||
ImportOptions,
|
||||
} from '@janhq/core'
|
||||
import { janApiClient } from './api'
|
||||
import { janProviderStore } from './store'
|
||||
import type { JanChatMessage } from './types'
|
||||
|
||||
// Jan models support tools via MCP
|
||||
const JAN_MODEL_CAPABILITIES = ['tools'] as const
|
||||
|
||||
export default class JanProviderExtension extends AIEngine {
|
||||
readonly provider = 'jan'
|
||||
private activeSessions: Map<string, SessionInfo> = new Map()
|
||||
private isDesktopTauri: boolean = false
|
||||
|
||||
override async onLoad() {
|
||||
// Detect if we're running on desktop Tauri (not mobile)
|
||||
this.isDesktopTauri = this.detectDesktopTauri()
|
||||
|
||||
// On desktop Tauri, do not load this extension
|
||||
if (this.isDesktopTauri) {
|
||||
console.log(
|
||||
'Jan Provider Extension: Disabled on desktop Tauri (use llamacpp instead)'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('Loading Jan Provider Extension...')
|
||||
|
||||
try {
|
||||
// Check and clear invalid Jan models (capabilities mismatch)
|
||||
this.validateJanModelsLocalStorage()
|
||||
|
||||
// Don't initialize here - wait until auth service is ready
|
||||
// Models will be fetched lazily when list() is called
|
||||
console.log('Jan Provider Extension loaded successfully (models will be fetched on demand)')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Jan Provider Extension:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
super.onLoad()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if we're on desktop Tauri (not mobile)
|
||||
* Mobile platforms (iOS/Android) will have IS_IOS or IS_ANDROID set
|
||||
* Web platform will have IS_WEB_APP set to true
|
||||
* Desktop Tauri will have neither (or IS_WEB_APP = false)
|
||||
*/
|
||||
private detectDesktopTauri(): boolean {
|
||||
// Check if we're in a browser environment
|
||||
if (typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for mobile-specific flags
|
||||
const IS_IOS = (window as any).IS_IOS
|
||||
const IS_ANDROID = (window as any).IS_ANDROID
|
||||
const IS_WEB_APP = (window as any).IS_WEB_APP
|
||||
|
||||
// If we're on mobile, not desktop
|
||||
if (IS_IOS === true || IS_ANDROID === true) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If we're on web app, not desktop
|
||||
if (IS_WEB_APP === true || IS_WEB_APP === 'true') {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if Tauri API is available
|
||||
const hasTauriAPI = !!(window as any).__TAURI__
|
||||
|
||||
// If Tauri API is available and we're not on mobile or web, we're on desktop Tauri
|
||||
return hasTauriAPI
|
||||
}
|
||||
|
||||
// Verify Jan models capabilities in localStorage
|
||||
private validateJanModelsLocalStorage() {
|
||||
try {
|
||||
console.log('Validating Jan models in localStorage...')
|
||||
const storageKey = 'model-provider'
|
||||
const data = localStorage.getItem(storageKey)
|
||||
if (!data) return
|
||||
|
||||
const parsed = JSON.parse(data)
|
||||
if (!parsed?.state?.providers) return
|
||||
|
||||
let hasInvalidModel = false
|
||||
|
||||
for (const provider of parsed.state.providers) {
|
||||
if (provider.provider === 'jan' && provider.models) {
|
||||
for (const model of provider.models) {
|
||||
console.log(`Checking Jan model: ${model.id}`, model.capabilities)
|
||||
if (
|
||||
JSON.stringify(model.capabilities) !==
|
||||
JSON.stringify(JAN_MODEL_CAPABILITIES)
|
||||
) {
|
||||
hasInvalidModel = true
|
||||
console.log(
|
||||
`Found invalid Jan model: ${model.id}, clearing localStorage`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasInvalidModel) break
|
||||
}
|
||||
|
||||
if (hasInvalidModel) {
|
||||
localStorage.removeItem(storageKey)
|
||||
const afterRemoval = localStorage.getItem(storageKey)
|
||||
if (afterRemoval) {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
state: { providers: [] },
|
||||
version: parsed.version || 3,
|
||||
})
|
||||
)
|
||||
}
|
||||
console.log(
|
||||
'Cleared model-provider from localStorage due to invalid Jan capabilities'
|
||||
)
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check Jan models:', error)
|
||||
}
|
||||
}
|
||||
|
||||
override async onUnload() {
|
||||
console.log('Unloading Jan Provider Extension...')
|
||||
|
||||
for (const sessionId of this.activeSessions.keys()) {
|
||||
await this.unload(sessionId)
|
||||
}
|
||||
|
||||
janProviderStore.reset()
|
||||
console.log('Jan Provider Extension unloaded')
|
||||
}
|
||||
|
||||
async get(modelId: string): Promise<modelInfo | undefined> {
|
||||
if (this.isDesktopTauri) return undefined
|
||||
|
||||
return janApiClient
|
||||
.getModels()
|
||||
.then((list) => list.find((e) => e.id === modelId))
|
||||
.then((model) =>
|
||||
model
|
||||
? {
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
quant_type: undefined,
|
||||
providerId: this.provider,
|
||||
port: 443,
|
||||
sizeBytes: 0,
|
||||
tags: [],
|
||||
path: undefined,
|
||||
owned_by: model.owned_by,
|
||||
object: model.object,
|
||||
capabilities: [...JAN_MODEL_CAPABILITIES],
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
async list(): Promise<modelInfo[]> {
|
||||
if (this.isDesktopTauri) return []
|
||||
|
||||
try {
|
||||
const janModels = await janApiClient.getModels()
|
||||
|
||||
return janModels.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.id,
|
||||
quant_type: undefined,
|
||||
providerId: this.provider,
|
||||
port: 443,
|
||||
sizeBytes: 0,
|
||||
tags: [],
|
||||
path: undefined,
|
||||
owned_by: model.owned_by,
|
||||
object: model.object,
|
||||
capabilities: [...JAN_MODEL_CAPABILITIES],
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to list Jan models:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async load(modelId: string, _settings?: any): Promise<SessionInfo> {
|
||||
try {
|
||||
const sessionId = `jan-${modelId}-${Date.now()}`
|
||||
|
||||
const sessionInfo: SessionInfo = {
|
||||
pid: Date.now(),
|
||||
port: 443,
|
||||
model_id: modelId,
|
||||
model_path: `remote:${modelId}`,
|
||||
api_key: '',
|
||||
}
|
||||
|
||||
this.activeSessions.set(sessionId, sessionInfo)
|
||||
|
||||
console.log(
|
||||
`Jan model session created: ${sessionId} for model ${modelId}`
|
||||
)
|
||||
return sessionInfo
|
||||
} catch (error) {
|
||||
console.error(`Failed to load Jan model ${modelId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async unload(sessionId: string): Promise<UnloadResult> {
|
||||
try {
|
||||
const session = this.activeSessions.get(sessionId)
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Session ${sessionId} not found`,
|
||||
}
|
||||
}
|
||||
|
||||
this.activeSessions.delete(sessionId)
|
||||
console.log(`Jan model session unloaded: ${sessionId}`)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error(`Failed to unload Jan session ${sessionId}:`, error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async chat(
|
||||
opts: chatCompletionRequest,
|
||||
abortController?: AbortController
|
||||
): Promise<chatCompletion | AsyncIterable<chatCompletionChunk>> {
|
||||
try {
|
||||
if (abortController?.signal?.aborted) {
|
||||
throw new Error('Request was aborted')
|
||||
}
|
||||
|
||||
const modelId = opts.model
|
||||
if (!modelId) {
|
||||
throw new Error('Model ID is required')
|
||||
}
|
||||
|
||||
const janMessages: JanChatMessage[] = opts.messages.map((msg) => ({
|
||||
role: msg.role as 'system' | 'user' | 'assistant',
|
||||
content:
|
||||
typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content),
|
||||
}))
|
||||
|
||||
const janRequest = {
|
||||
model: modelId,
|
||||
messages: janMessages,
|
||||
conversation_id: opts.thread_id,
|
||||
temperature: opts.temperature ?? undefined,
|
||||
max_tokens: opts.n_predict ?? undefined,
|
||||
top_p: opts.top_p ?? undefined,
|
||||
frequency_penalty: opts.frequency_penalty ?? undefined,
|
||||
presence_penalty: opts.presence_penalty ?? undefined,
|
||||
stream: opts.stream ?? false,
|
||||
stop: opts.stop ?? undefined,
|
||||
tools: opts.tools ?? undefined,
|
||||
tool_choice: opts.tool_choice ?? undefined,
|
||||
}
|
||||
|
||||
if (opts.stream) {
|
||||
return this.createStreamingGenerator(janRequest, abortController)
|
||||
} else {
|
||||
const response = await janApiClient.createChatCompletion(janRequest)
|
||||
|
||||
if (abortController?.signal?.aborted) {
|
||||
throw new Error('Request was aborted')
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
object: 'chat.completion' as const,
|
||||
created: response.created,
|
||||
model: response.model,
|
||||
choices: response.choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
message: {
|
||||
role: choice.message.role,
|
||||
content: choice.message.content,
|
||||
reasoning: choice.message.reasoning,
|
||||
reasoning_content: choice.message.reasoning_content,
|
||||
tool_calls: choice.message.tool_calls,
|
||||
},
|
||||
finish_reason: (choice.finish_reason || 'stop') as
|
||||
| 'stop'
|
||||
| 'length'
|
||||
| 'tool_calls'
|
||||
| 'content_filter'
|
||||
| 'function_call',
|
||||
})),
|
||||
usage: response.usage,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Jan chat completion failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async *createStreamingGenerator(
|
||||
janRequest: any,
|
||||
abortController?: AbortController
|
||||
) {
|
||||
let resolve: () => void
|
||||
let reject: (error: Error) => void
|
||||
const chunks: any[] = []
|
||||
let isComplete = false
|
||||
let error: Error | null = null
|
||||
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
|
||||
const abortListener = () => {
|
||||
error = new Error('Request was aborted')
|
||||
reject(error)
|
||||
}
|
||||
|
||||
if (abortController?.signal) {
|
||||
if (abortController.signal.aborted) {
|
||||
throw new Error('Request was aborted')
|
||||
}
|
||||
abortController.signal.addEventListener('abort', abortListener)
|
||||
}
|
||||
|
||||
try {
|
||||
janApiClient.createStreamingChatCompletion(
|
||||
janRequest,
|
||||
(chunk) => {
|
||||
if (abortController?.signal?.aborted) {
|
||||
return
|
||||
}
|
||||
const streamChunk = {
|
||||
id: chunk.id,
|
||||
object: chunk.object,
|
||||
created: chunk.created,
|
||||
model: chunk.model,
|
||||
choices: chunk.choices.map((choice) => ({
|
||||
index: choice.index,
|
||||
delta: {
|
||||
role: choice.delta.role,
|
||||
content: choice.delta.content,
|
||||
reasoning: choice.delta.reasoning,
|
||||
reasoning_content: choice.delta.reasoning_content,
|
||||
tool_calls: choice.delta.tool_calls,
|
||||
},
|
||||
finish_reason: choice.finish_reason,
|
||||
})),
|
||||
}
|
||||
chunks.push(streamChunk)
|
||||
},
|
||||
() => {
|
||||
isComplete = true
|
||||
resolve()
|
||||
},
|
||||
(err) => {
|
||||
error = err
|
||||
reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
let yieldedIndex = 0
|
||||
while (!isComplete && !error) {
|
||||
if (abortController?.signal?.aborted) {
|
||||
throw new Error('Request was aborted')
|
||||
}
|
||||
|
||||
while (yieldedIndex < chunks.length) {
|
||||
yield chunks[yieldedIndex]
|
||||
yieldedIndex++
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
|
||||
while (yieldedIndex < chunks.length) {
|
||||
yield chunks[yieldedIndex]
|
||||
yieldedIndex++
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await promise
|
||||
} finally {
|
||||
if (abortController?.signal) {
|
||||
abortController.signal.removeEventListener('abort', abortListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async delete(modelId: string): Promise<void> {
|
||||
throw new Error(
|
||||
`Delete operation not supported for remote Jan API model: ${modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
async update(modelId: string, _model: Partial<modelInfo>): Promise<void> {
|
||||
throw new Error(
|
||||
`Update operation not supported for remote Jan API model: ${modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
async import(modelId: string, _opts: ImportOptions): Promise<void> {
|
||||
throw new Error(
|
||||
`Import operation not supported for remote Jan API model: ${modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
async abortImport(modelId: string): Promise<void> {
|
||||
throw new Error(
|
||||
`Abort import operation not supported for remote Jan API model: ${modelId}`
|
||||
)
|
||||
}
|
||||
|
||||
async getLoadedModels(): Promise<string[]> {
|
||||
return Array.from(this.activeSessions.values()).map(
|
||||
(session) => session.model_id
|
||||
)
|
||||
}
|
||||
|
||||
async isToolSupported(modelId: string): Promise<boolean> {
|
||||
console.log(`Checking tool support for Jan model ${modelId}: supported`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
90
extensions/jan-provider-extension/src/store.ts
Normal file
90
extensions/jan-provider-extension/src/store.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Jan Provider Store
|
||||
* Zustand-based state management for Jan provider authentication and models
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { JanModel } from './types'
|
||||
|
||||
export interface JanProviderState {
|
||||
isAuthenticated: boolean
|
||||
isInitializing: boolean
|
||||
models: JanModel[]
|
||||
isLoadingModels: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface JanProviderActions {
|
||||
setAuthenticated: (isAuthenticated: boolean) => void
|
||||
setInitializing: (isInitializing: boolean) => void
|
||||
setModels: (models: JanModel[]) => void
|
||||
setLoadingModels: (isLoadingModels: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
clearError: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export type JanProviderStore = JanProviderState & JanProviderActions
|
||||
|
||||
const initialState: JanProviderState = {
|
||||
isAuthenticated: false,
|
||||
isInitializing: true,
|
||||
models: [],
|
||||
isLoadingModels: false,
|
||||
error: null,
|
||||
}
|
||||
|
||||
export const useJanProviderStore = create<JanProviderStore>((set) => ({
|
||||
...initialState,
|
||||
|
||||
setAuthenticated: (isAuthenticated: boolean) =>
|
||||
set({ isAuthenticated, error: null }),
|
||||
|
||||
setInitializing: (isInitializing: boolean) =>
|
||||
set({ isInitializing }),
|
||||
|
||||
setModels: (models: JanModel[]) =>
|
||||
set({ models, isLoadingModels: false }),
|
||||
|
||||
setLoadingModels: (isLoadingModels: boolean) =>
|
||||
set({ isLoadingModels }),
|
||||
|
||||
setError: (error: string | null) =>
|
||||
set({ error }),
|
||||
|
||||
clearError: () =>
|
||||
set({ error: null }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isInitializing: false,
|
||||
models: [],
|
||||
isLoadingModels: false,
|
||||
error: null,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Export a store instance for non-React usage
|
||||
export const janProviderStore = {
|
||||
// Store access methods
|
||||
getState: useJanProviderStore.getState,
|
||||
setState: useJanProviderStore.setState,
|
||||
subscribe: useJanProviderStore.subscribe,
|
||||
|
||||
// Direct action methods
|
||||
setAuthenticated: (isAuthenticated: boolean) =>
|
||||
useJanProviderStore.getState().setAuthenticated(isAuthenticated),
|
||||
setInitializing: (isInitializing: boolean) =>
|
||||
useJanProviderStore.getState().setInitializing(isInitializing),
|
||||
setModels: (models: JanModel[]) =>
|
||||
useJanProviderStore.getState().setModels(models),
|
||||
setLoadingModels: (isLoadingModels: boolean) =>
|
||||
useJanProviderStore.getState().setLoadingModels(isLoadingModels),
|
||||
setError: (error: string | null) =>
|
||||
useJanProviderStore.getState().setError(error),
|
||||
clearError: () =>
|
||||
useJanProviderStore.getState().clearError(),
|
||||
reset: () =>
|
||||
useJanProviderStore.getState().reset(),
|
||||
}
|
||||
89
extensions/jan-provider-extension/src/types.ts
Normal file
89
extensions/jan-provider-extension/src/types.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Jan Provider Types
|
||||
*/
|
||||
|
||||
export interface JanModel {
|
||||
id: string
|
||||
object: string
|
||||
owned_by: string
|
||||
}
|
||||
|
||||
export interface JanModelsResponse {
|
||||
object: string
|
||||
data: JanModel[]
|
||||
}
|
||||
|
||||
export interface JanChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
reasoning?: string
|
||||
reasoning_content?: string
|
||||
tool_calls?: any[]
|
||||
}
|
||||
|
||||
export interface JanChatCompletionRequest {
|
||||
model: string
|
||||
messages: JanChatMessage[]
|
||||
conversation_id?: string
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
top_p?: number
|
||||
frequency_penalty?: number
|
||||
presence_penalty?: number
|
||||
stream?: boolean
|
||||
stop?: string | string[]
|
||||
tools?: any[]
|
||||
tool_choice?: any
|
||||
}
|
||||
|
||||
export interface JanChatCompletionChoice {
|
||||
index: number
|
||||
message: JanChatMessage
|
||||
finish_reason: string | null
|
||||
}
|
||||
|
||||
export interface JanChatCompletionResponse {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
model: string
|
||||
choices: JanChatCompletionChoice[]
|
||||
usage?: {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface JanChatCompletionChunk {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
delta: {
|
||||
role?: string
|
||||
content?: string
|
||||
reasoning?: string
|
||||
reasoning_content?: string
|
||||
tool_calls?: any[]
|
||||
}
|
||||
finish_reason: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public details?: string
|
||||
) {
|
||||
super(`API Error ${status}: ${statusText}${details ? ` - ${details}` : ''}`)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
|
||||
isStatus(code: number): boolean {
|
||||
return this.status === code
|
||||
}
|
||||
}
|
||||
7
extensions/jan-provider-extension/tsconfig.json
Normal file
7
extensions/jan-provider-extension/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../core/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
||||
8
extensions/jan-provider-extension/vitest.config.ts
Normal file
8
extensions/jan-provider-extension/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,8 +3,8 @@
|
||||
"build": {
|
||||
"devUrl": null,
|
||||
"frontendDist": "../web-app/dist",
|
||||
"beforeDevCommand": "cross-env IS_DEV=true IS_ANDROID=true yarn build:web",
|
||||
"beforeBuildCommand": "cross-env IS_ANDROID=true yarn build:web"
|
||||
"beforeDevCommand": "cross-env IS_ANDROID=true yarn build:extensions && cross-env IS_DEV=true IS_ANDROID=true yarn build:web",
|
||||
"beforeBuildCommand": "cross-env IS_ANDROID=true yarn build:extensions && cross-env IS_ANDROID=true yarn build:web"
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
|
||||
163
web-app/src/__tests__/jan-provider-mobile.test.ts
Normal file
163
web-app/src/__tests__/jan-provider-mobile.test.ts
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Jan Provider Mobile Integration Tests
|
||||
* Verifies that Jan Provider extension loads correctly on mobile platforms
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
describe('Jan Provider on Mobile Platforms', () => {
|
||||
beforeEach(() => {
|
||||
// Clear any existing global state
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Platform Detection', () => {
|
||||
it('should enable on iOS platform', () => {
|
||||
// Simulate iOS environment
|
||||
;(global as any).IS_IOS = true
|
||||
;(global as any).IS_ANDROID = false
|
||||
;(global as any).__TAURI__ = { invoke: vi.fn() }
|
||||
|
||||
// Extension should detect iOS and remain enabled
|
||||
const isDesktopTauri = !(
|
||||
(global as any).IS_IOS === true || (global as any).IS_ANDROID === true
|
||||
)
|
||||
|
||||
expect(isDesktopTauri).toBe(false)
|
||||
})
|
||||
|
||||
it('should enable on Android platform', () => {
|
||||
// Simulate Android environment
|
||||
;(global as any).IS_IOS = false
|
||||
;(global as any).IS_ANDROID = true
|
||||
;(global as any).__TAURI__ = { invoke: vi.fn() }
|
||||
|
||||
// Extension should detect Android and remain enabled
|
||||
const isDesktopTauri = !(
|
||||
(global as any).IS_IOS === true || (global as any).IS_ANDROID === true
|
||||
)
|
||||
|
||||
expect(isDesktopTauri).toBe(false)
|
||||
})
|
||||
|
||||
it('should disable on desktop Tauri', () => {
|
||||
// Simulate desktop Tauri environment
|
||||
;(global as any).IS_IOS = false
|
||||
;(global as any).IS_ANDROID = false
|
||||
;(global as any).__TAURI__ = { invoke: vi.fn() }
|
||||
|
||||
// Extension should detect desktop and disable
|
||||
const isDesktopTauri = !(
|
||||
(global as any).IS_IOS === true || (global as any).IS_ANDROID === true
|
||||
)
|
||||
|
||||
expect(isDesktopTauri).toBe(true)
|
||||
})
|
||||
|
||||
it('should enable on web platform', () => {
|
||||
// Simulate web environment (no Tauri)
|
||||
;(global as any).IS_IOS = false
|
||||
;(global as any).IS_ANDROID = false
|
||||
delete (global as any).__TAURI__
|
||||
|
||||
// Extension should work on web
|
||||
const hasTauriAPI = !!(global as any).__TAURI__
|
||||
expect(hasTauriAPI).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Authentication Requirements', () => {
|
||||
it('should require auth service for API calls', () => {
|
||||
const hasAuthService = typeof (global as any).window?.janAuthService !== 'undefined'
|
||||
|
||||
// Auth service should be available or waited for
|
||||
expect(
|
||||
hasAuthService ||
|
||||
'waitForAuthService should be called before API requests'
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('API Configuration', () => {
|
||||
it('should use correct default API base', () => {
|
||||
const expectedDefault = 'https://api.jan.ai/v1'
|
||||
|
||||
// The getApiBase function should return the default when not configured
|
||||
const getApiBase = (): string => {
|
||||
if (typeof (globalThis as any).JAN_API_BASE !== 'undefined') {
|
||||
return (globalThis as any).JAN_API_BASE
|
||||
}
|
||||
if (typeof import.meta !== 'undefined' && import.meta.env?.JAN_API_BASE) {
|
||||
return import.meta.env.JAN_API_BASE
|
||||
}
|
||||
return expectedDefault
|
||||
}
|
||||
|
||||
expect(getApiBase()).toBe(expectedDefault)
|
||||
})
|
||||
|
||||
it('should respect environment variable override', () => {
|
||||
const customBase = 'https://custom.api.jan.ai/v1'
|
||||
;(globalThis as any).JAN_API_BASE = customBase
|
||||
|
||||
const getApiBase = (): string => {
|
||||
if (typeof (globalThis as any).JAN_API_BASE !== 'undefined') {
|
||||
return (globalThis as any).JAN_API_BASE
|
||||
}
|
||||
return 'https://api.jan.ai/v1'
|
||||
}
|
||||
|
||||
expect(getApiBase()).toBe(customBase)
|
||||
|
||||
// Cleanup
|
||||
delete (globalThis as any).JAN_API_BASE
|
||||
})
|
||||
})
|
||||
|
||||
describe('Model Capabilities', () => {
|
||||
it('should support tools capability', () => {
|
||||
const JAN_MODEL_CAPABILITIES = ['tools'] as const
|
||||
|
||||
expect(JAN_MODEL_CAPABILITIES).toContain('tools')
|
||||
expect(JAN_MODEL_CAPABILITIES.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Extension Bundling', () => {
|
||||
it('should be included in mobile bundled extensions', () => {
|
||||
const bundledExtensions = [
|
||||
'@janhq/conversational-extension',
|
||||
'@janhq/jan-provider-extension', // Should be present
|
||||
]
|
||||
|
||||
expect(bundledExtensions).toContain('@janhq/jan-provider-extension')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Provider Service Integration', () => {
|
||||
it('should handle provider loading errors gracefully', async () => {
|
||||
const mockProviders = []
|
||||
|
||||
// Simulate a provider that fails to load
|
||||
const loadProvider = async (providerName: string) => {
|
||||
try {
|
||||
if (providerName === 'failing-provider') {
|
||||
throw new Error('Provider failed to load')
|
||||
}
|
||||
mockProviders.push({ provider: providerName })
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load runtime provider ${providerName}:`, error)
|
||||
// Should continue with other providers
|
||||
}
|
||||
}
|
||||
|
||||
await loadProvider('jan')
|
||||
await loadProvider('failing-provider')
|
||||
await loadProvider('llamacpp')
|
||||
|
||||
// Jan provider should have loaded successfully
|
||||
expect(mockProviders).toHaveLength(2)
|
||||
expect(mockProviders.some(p => p.provider === 'jan')).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -9,6 +9,7 @@
|
||||
import { TauriCoreService } from './tauri'
|
||||
import type { ExtensionManifest } from '@/lib/extension'
|
||||
import JanConversationalExtension from '@janhq/conversational-extension'
|
||||
import JanProviderExtension from '@janhq/jan-provider-extension'
|
||||
|
||||
export class MobileCoreService extends TauriCoreService {
|
||||
/**
|
||||
@ -54,6 +55,15 @@ export class MobileCoreService extends TauriCoreService {
|
||||
'1.0.0'
|
||||
)
|
||||
|
||||
const janProviderExt = new JanProviderExtension(
|
||||
'built-in',
|
||||
'@janhq/jan-provider-extension',
|
||||
'Jan Provider',
|
||||
true,
|
||||
'Provides remote model inference through Jan API',
|
||||
'1.0.0'
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
name: '@janhq/conversational-extension',
|
||||
@ -64,6 +74,15 @@ export class MobileCoreService extends TauriCoreService {
|
||||
version: '1.0.0',
|
||||
extensionInstance: conversationalExt,
|
||||
},
|
||||
{
|
||||
name: '@janhq/jan-provider-extension',
|
||||
productName: 'Jan Provider',
|
||||
url: 'built-in',
|
||||
active: true,
|
||||
description: 'Provides remote model inference through Jan API',
|
||||
version: '1.0.0',
|
||||
extensionInstance: janProviderExt,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,8 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@janhq/conversational-extension": ["../extensions/conversational-extension/src/index.ts"]
|
||||
"@janhq/conversational-extension": ["../extensions/conversational-extension/src/index.ts"],
|
||||
"@janhq/jan-provider-extension": ["../extensions/jan-provider-extension/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@ -8,7 +8,8 @@
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@janhq/conversational-extension": ["../extensions/conversational-extension/src/index.ts"]
|
||||
"@janhq/conversational-extension": ["../extensions/conversational-extension/src/index.ts"],
|
||||
"@janhq/jan-provider-extension": ["../extensions/jan-provider-extension/src/index.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,6 +65,7 @@ export default defineConfig(({ mode }) => {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@janhq/conversational-extension': path.resolve(__dirname, '../extensions/conversational-extension/src/index.ts'),
|
||||
'@janhq/jan-provider-extension': path.resolve(__dirname, '../extensions/jan-provider-extension/src/index.ts'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user