feat: Add jan provider extensions for mobile

This commit is contained in:
Vanalite 2025-10-06 09:01:57 +07:00
parent fa61163350
commit 79e915d97d
18 changed files with 2368 additions and 11 deletions

View 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"
}

View 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'),
},
})

View File

@ -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)
})
})

View 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()

View 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}` }
}

View File

@ -0,0 +1 @@
export { default } from './provider'

View 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
}
}

View 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(),
}

View 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
}
}

View File

@ -0,0 +1,7 @@
{
"extends": "../../core/tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": ["./src/**/*"]
}

View 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

View File

@ -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": {

View 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)
})
})

View File

@ -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,
},
]
}
}

View File

@ -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"],

View File

@ -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"]
}
}
}

View File

@ -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: {