Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@ibm-cloud/watsonx-ai": "^1.7.0",
"@langchain/community": "^1.0.0",
"@langchain/core": "^1.0.2",
"@langchain/openai": "^1.0.0",
"@langchain/textsplitters": "^1.0.0",
"@oclif/core": "^4",
"@oclif/plugin-help": "^6",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 12 additions & 6 deletions src/commands/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {Args, Command, Flags} from '@oclif/core'

import {createClient} from '../core/providers/watsonx.js'
import {MarkdownTranslator} from '../core/translators/markdown.js'
import {createProviderFromProfile} from '../lib/profile/factory.js'
import {loadProfile} from '../lib/profile/storage.js'

export default class Markdown extends Command {
static args = {
Expand All @@ -12,16 +13,20 @@ export default class Markdown extends Command {
}
static description = 'Translate markdown'
static examples = [
'<%= config.bin %> <%= command.id %> --from EN --to ES "Hello"',
'<%= config.bin %> <%= command.id %> --from EN --to ES --stream "Hello"',
'cat doc.md | <%= config.bin %> <%= command.id %> --from EN --to ES',
'echo "# Hello" | <%= config.bin %> <%= command.id %> --from EN --to ES',
'<%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES "Hello"',
'<%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES --stream "Hello"',
'cat doc.md | <%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES',
'echo "# Hello" | <%= config.bin %> <%= command.id %> --profile default-openai --from EN --to ES',
]
static flags = {
from: Flags.string({
description: 'Source language',
required: true,
}),
profile: Flags.string({
description: 'Profile to use for translation',
required: true,
}),
stream: Flags.boolean({
default: false,
description: 'Stream the translation output',
Expand All @@ -48,7 +53,8 @@ export default class Markdown extends Command {
input = Buffer.concat(chunks).toString('utf8')
}

const llm = createClient()
const profile = loadProfile(flags.profile)
const llm = createProviderFromProfile(profile)
const translator = new MarkdownTranslator(llm)

if (flags.stream) {
Expand Down
29 changes: 29 additions & 0 deletions src/commands/profiles/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {Args, Command} from '@oclif/core'

import {deleteProfile} from '../../lib/profile/storage.js'

export default class ProfilesDelete extends Command {
static args = {
name: Args.string({
description: 'Profile name to delete',
required: true,
}),
}
static description = 'Delete a profile'
static examples = ['<%= config.bin %> <%= command.id %> my-profile']

async run(): Promise<void> {
const {args} = await this.parse(ProfilesDelete)

try {
deleteProfile(args.name)
this.log(`Profile "${args.name}" deleted successfully`)
} catch (error) {
if (error instanceof Error) {
this.error(error.message)
}

throw error
}
}
}
21 changes: 21 additions & 0 deletions src/commands/profiles/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {Command} from '@oclif/core'

import {listProfiles} from '../../lib/profile/storage.js'

export default class ProfilesList extends Command {
static description = 'List all profiles'
static examples = ['<%= config.bin %> <%= command.id %>']

async run(): Promise<void> {
const profiles = listProfiles()

if (profiles.length === 0) {
this.log('No profiles found')
return
}

for (const profile of profiles) {
this.log(profile)
}
}
}
84 changes: 84 additions & 0 deletions src/commands/profiles/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {Args, Command, Flags} from '@oclif/core'

import type {OpenAIProfileConfig, WatsonxProfileConfig} from '../../lib/profile/types.js'

import {saveProfile} from '../../lib/profile/storage.js'

export default class ProfilesSet extends Command {
static args = {
name: Args.string({
description: 'Profile name',
required: true,
}),
}
static description = 'Create or update a profile'
static examples = [
'<%= config.bin %> <%= command.id %> my-openai-profile --provider openai --api-key sk-... --model gpt-4o',
'<%= config.bin %> <%= command.id %> my-watsonx-profile --provider watsonx --api-key ... --project-id ... --service-url https://... --model ibm/granite-4-h-small',
]
static flags = {
'api-key': Flags.string({
description: 'API key for the provider',
required: true,
}),
model: Flags.string({
description: 'Model to use',
required: true,
}),
'project-id': Flags.string({
description: 'Watsonx project ID (required for watsonx)',
required: false,
}),
provider: Flags.string({
description: 'LLM provider',
options: ['openai', 'watsonx'],
required: true,
}),
'service-url': Flags.string({
description: 'Watsonx service URL (required for watsonx)',
required: false,
}),
}

async run(): Promise<void> {
const {args, flags} = await this.parse(ProfilesSet)

if (flags.provider === 'openai') {
const config: OpenAIProfileConfig = {
apiKey: flags['api-key'],
model: flags.model,
}

saveProfile({
config,
name: args.name,
provider: 'openai',
})

this.log(`Profile "${args.name}" saved successfully`)
} else if (flags.provider === 'watsonx') {
if (!flags['project-id']) {
this.error('--project-id is required for watsonx provider')
}

if (!flags['service-url']) {
this.error('--service-url is required for watsonx provider')
}

const config: WatsonxProfileConfig = {
apiKey: flags['api-key'],
model: flags.model,
projectId: flags['project-id'],
serviceUrl: flags['service-url'],
}

saveProfile({
config,
name: args.name,
provider: 'watsonx',
})

this.log(`Profile "${args.name}" saved successfully`)
}
}
}
11 changes: 11 additions & 0 deletions src/core/providers/fake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {FakeListChatModel} from '@langchain/core/utils/testing'

export interface FakeConfig {
responses: string[]
}

export function createClient(config: FakeConfig): FakeListChatModel {
return new FakeListChatModel({
responses: config.responses,
})
}
18 changes: 18 additions & 0 deletions src/core/providers/openai.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {ChatOpenAI} from '@langchain/openai'

export interface OpenAIConfig {
apiKey: string
maxTokens?: number
model: string
temperature?: number
}

export function createClient(config: OpenAIConfig): ChatOpenAI {
return new ChatOpenAI({
apiKey: config.apiKey,
maxRetries: 3,
maxTokens: config.maxTokens || 2000,
model: config.model,
temperature: config.temperature || 0.3,
})
}
41 changes: 8 additions & 33 deletions src/core/providers/watsonx.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,24 @@
import {ChatWatsonx} from '@langchain/community/chat_models/ibm'

/**
* Configuration for watsonx.ai client
*/
export interface WatsonxConfig {
apiKey: string
maxNewTokens?: number
model?: string
model: string
projectId: string
serviceUrl: string
temperature?: number
}

/**
* Creates and returns a configured watsonx.ai chat model with IAM authentication
*/
export function createClient(config?: Partial<WatsonxConfig>): ChatWatsonx {
// Get configuration from environment variables or provided config
const serviceUrl = config?.serviceUrl || process.env.WATSONX_AI_SERVICE_URL
const projectId = config?.projectId || process.env.WATSONX_AI_PROJECT_ID
const apiKey = config?.apiKey || process.env.WATSONX_AI_APIKEY

// Validate required configuration
if (!serviceUrl) {
throw new Error('WATSONX_AI_SERVICE_URL is required')
}

if (!projectId) {
throw new Error('WATSONX_AI_PROJECT_ID is required')
}

if (!apiKey) {
throw new Error('WATSONX_AI_APIKEY is required')
}

// Create and return the chat model with IAM authentication
export function createClient(config: WatsonxConfig): ChatWatsonx {
return new ChatWatsonx({
maxRetries: 3,
maxTokens: config?.maxNewTokens || 2000,
model: config?.model || 'ibm/granite-4-h-small',
projectId,
serviceUrl,
temperature: config?.temperature || 0.3,
maxTokens: config.maxNewTokens || 2000,
model: config.model,
projectId: config.projectId,
serviceUrl: config.serviceUrl,
temperature: config.temperature || 0.3,
version: '2024-05-31',
watsonxAIApikey: apiKey,
watsonxAIApikey: config.apiKey,
watsonxAIAuthType: 'iam',
})
}
23 changes: 23 additions & 0 deletions src/lib/profile/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {BaseChatModel} from '@langchain/core/language_models/chat_models'

import type {Profile} from './types.js'

import {createClient as createFakeClient} from '../../core/providers/fake.js'
import {createClient as createOpenAIClient} from '../../core/providers/openai.js'
import {createClient as createWatsonxClient} from '../../core/providers/watsonx.js'

export function createProviderFromProfile(profile: Profile): BaseChatModel {
switch (profile.provider) {
case 'fake': {
return createFakeClient(profile.config)
}

case 'openai': {
return createOpenAIClient(profile.config)
}

case 'watsonx': {
return createWatsonxClient(profile.config)
}
}
}
69 changes: 69 additions & 0 deletions src/lib/profile/storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
import {homedir} from 'node:os'
import {join} from 'node:path'

import type {Profile} from './types.js'

export function getProfilesDir(): string {
const home = process.env.HOME || homedir()
return join(home, '.config', 'translation-ai-cli', 'profiles')
}

export function ensureProfilesDir(): void {
const dir = getProfilesDir()
if (!existsSync(dir)) {
mkdirSync(dir, {recursive: true})
}
}

export function getProfilePath(name: string): string {
// Reject path traversal attempts, empty names, or names with path separators
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
throw new Error(`Invalid profile name: "${name}". Profile names cannot contain path separators or parent directory references.`)
}

return join(getProfilesDir(), `${name}.json`)
}

export function saveProfile(profile: Profile): void {
ensureProfilesDir()
const path = getProfilePath(profile.name)
writeFileSync(path, JSON.stringify(profile, null, 2), {
encoding: 'utf8',
mode: 0o600, // only owner can read/write
})
}

export function loadProfile(name: string): Profile {
const path = getProfilePath(name)
if (!existsSync(path)) {
throw new Error(`Profile "${name}" does not exist`)
}

const content = readFileSync(path, 'utf8')
return JSON.parse(content) as Profile
}

export function listProfiles(): string[] {
const dir = getProfilesDir()
if (!existsSync(dir)) {
return []
}

return readdirSync(dir)
.filter((file) => file.endsWith('.json'))
.map((file) => file.replace(/\.json$/, ''))
}

export function deleteProfile(name: string): void {
const path = getProfilePath(name)
if (!existsSync(path)) {
throw new Error(`Profile "${name}" does not exist`)
}

rmSync(path)
}

export function profileExists(name: string): boolean {
return existsSync(getProfilePath(name))
}
Loading