Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
30 changes: 30 additions & 0 deletions apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,36 @@ Generally speaking, you only need a new dev build if making dependency/native ch
4. `pnpm start`
5. open installed app on your phone

## Quick Chat backends

Kilo remains the default backend. Custom backends use the harness SDK's remote
model plugin, while the SDK owns conversations, tools, and compaction.

- Open **Chat → Manage backends** to configure an API root, protocol, credentials, headers, and models.
- Use Chat Completions, Responses, or Messages with the endpoint's API root, such as `https://api.openai.com/v1`.
- Chat Completions uses `max_completion_tokens` by default. Select `max_tokens` when the endpoint requires the legacy field.
- Enter models manually, or discover models when the endpoint supports discovery.
- Use **Test model connection** to send a short inference request. The provider can charge for this request.
- Enable tool calls only when the selected model and endpoint support them.

Profiles and credentials remain on the device and belong to the signed-in
account. Sign-out and account changes remove them.

- Each conversation retains its backend and upstream model identity, including queued messages.
- Failed questions retain their requested backend through Retry and restoration. Retry never substitutes the previous conversation's backend.
- Editing or deleting a backend requires an explicit selection before its conversations can continue.
- Changing backends shows a warning before sending conversation context to the new backend.
- Failed or unavailable backends never select Kilo automatically. Compaction uses the conversation's selected backend.
- Custom requests exclude Kilo authentication, Kilo headers, and browser cookies. Redirects are blocked.

HTTPS is the default. Local HTTP requires approval for the configured endpoint.
The warning covers unencrypted prompts, responses, credentials, and headers.

- Android release builds require HTTPS, including local servers.
- Android development builds permit approved loopback and private local HTTP endpoints.
- iOS permits approved local HTTP endpoints through the local networking exception.
- Public HTTP endpoints are rejected. HTTPS certificate checks remain enabled.

## App Store Kilo Pass Subscriptions

App Store Kilo Pass subscriptions require an EAS development build or TestFlight
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ const config: ExpoConfig = {
},
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
// Custom LAN backends require an app-level approval before each endpoint
// can use HTTP. ATS stays enabled for public HTTPS endpoints.
NSAppTransportSecurity: { NSAllowsLocalNetworking: true },
NSLocalNetworkUsageDescription: permissionLocales.en.ios.NSLocalNetworkUsageDescription,
// iOS reads this list, not the JS catalog. Without it the system treats
// the app as English-only, so OS-drawn text we cannot translate — the
// native Sign in with Apple button above all — stays English on a
Expand Down
87 changes: 87 additions & 0 deletions apps/mobile/plugins/permission-prompt-copy.json

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions apps/mobile/src/components/chat/backend-form-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from 'vitest';

import {
backendConnectionFromFields,
backendDraftFromFields,
type BackendFormFields,
} from './backend-form-state';

vi.mock('expo-crypto', () => ({ randomUUID: vi.fn() }));
vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() }));
vi.mock('sonner-native', () => ({ toast: { error: vi.fn() } }));
vi.mock('expo-secure-store', () => ({
getItemAsync: vi.fn().mockResolvedValue(null),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn(),
}));

const fields: BackendFormFields = {
name: ' Manual ',
baseUrl: 'https://models.example/v1/',
apiKey: '',
headers: '{}',
apiKind: 'messages',
completionTokenField: 'max_completion_tokens',
models: [
{
key: 'row',
id: 'manual-model',
name: 'My model',
contextWindow: '',
maxOutputTokens: '',
tools: false,
},
],
};

describe('backend form boundary', () => {
it('saves manual models without discovery and leaves unknown tool support off', () => {
expect(backendDraftFromFields(fields, false)).toMatchObject({
name: 'Manual',
baseUrl: 'https://models.example/v1',
models: [
{ id: 'manual-model', tools: false, contextWindow: undefined, maxOutputTokens: undefined },
],
});
});

it('discovery connection fields do not require a name or a manual model', () => {
expect(backendConnectionFromFields({ ...fields, name: '', models: [] }, false)).toMatchObject({
baseUrl: 'https://models.example/v1',
apiKind: 'messages',
});
expect(() => backendDraftFromFields({ ...fields, models: [] }, false)).toThrow('invalidInput');
});

it.each(['0', '-1', '1.5', 'NaN', 'Infinity', '2e3', '123junk', '9007199254740992'])(
'rejects malformed token limits: %s',
contextWindow => {
expect(() =>
backendDraftFromFields(
{ ...fields, models: fields.models.map(model => ({ ...model, contextWindow })) },
false
)
).toThrow('invalidInput');
}
);

it('accepts positive whole token limits and explicit tool support', () => {
expect(
backendDraftFromFields(
{
...fields,
models: fields.models.map(model => ({
...model,
contextWindow: '8192',
maxOutputTokens: '1024',
tools: true,
})),
},
false
).models[0]
).toMatchObject({ contextWindow: 8192, maxOutputTokens: 1024, tools: true });
});

it.each(['max_completion_tokens', 'max_tokens'] as const)(
'retains %s for saves and connection checks',
completionTokenField => {
const selected = { ...fields, apiKind: 'chat_completions' as const, completionTokenField };
expect(backendDraftFromFields(selected, false).completionTokenField).toBe(
completionTokenField
);
expect(backendConnectionFromFields(selected, false).completionTokenField).toBe(
completionTokenField
);
}
);

it.each([
'not-json-secret',
'[]',
'null',
'{"Authorization": 42}',
'{"x-kilocode-feature":"mobile-chat"}',
'{"bad header":"private"}',
String.raw`{"Authorization":"private\r\nsecond: header"}`,
])('refuses invalid headers without echoing input: %s', headers => {
expect(() => backendDraftFromFields({ ...fields, headers }, false)).toThrow('invalidInput');
expect(() => backendConnectionFromFields({ ...fields, headers }, false)).toThrow(
'invalidInput'
);
});

it('requires approval before saving an otherwise valid local HTTP profile', () => {
expect(() =>
backendDraftFromFields({ ...fields, baseUrl: 'http://localhost:8080/v1' }, false)
).toThrow('httpApprovalRequired');
expect(
backendDraftFromFields({ ...fields, baseUrl: 'http://localhost:8080/v1' }, true)
.allowLocalHttp
).toBe(true);
});
});
77 changes: 77 additions & 0 deletions apps/mobile/src/components/chat/backend-form-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { type ApiKind } from '@kilocode/harness-sdk';
import { randomUUID } from 'expo-crypto';

import {
type BackendConnection,
type BackendModel,
type ChatBackendDraft,
parseChatBackendHeaders,
validateBackendConnection,
validateChatBackendDraft,
} from '@/lib/chat/backend-store';

export const API_KINDS = {
chat_completions: 'modelChat.backends.chatCompletions',
responses: 'modelChat.backends.responses',
messages: 'modelChat.backends.messages',
} as const satisfies Record<ApiKind, string>;

export type BackendModelFields = {
key: string;
id: string;
name: string;
contextWindow: string;
maxOutputTokens: string;
tools: boolean;
};
export type BackendFormFields = {
name: string;
baseUrl: string;
apiKey: string;
headers: string;
apiKind: ApiKind;
completionTokenField: NonNullable<ChatBackendDraft['completionTokenField']>;
models: BackendModelFields[];
};

export function modelFields(model?: BackendModel): BackendModelFields {
return {
key: randomUUID(),
id: model?.id ?? '',
name: model?.name ?? '',
contextWindow: model?.contextWindow?.toString() ?? '',
maxOutputTokens: model?.maxOutputTokens?.toString() ?? '',
tools: model?.tools ?? false,
};
}

function optionalTokenLimit(input: string): number | undefined {
const trimmed = input.trim();
if (trimmed === '') {
return undefined;
}
return /^\d+$/u.test(trimmed) ? Number(trimmed) : Number.NaN;
}

export function backendDraftFromFields(
fields: BackendFormFields,
allowLocalHttp: boolean
): ChatBackendDraft {
const headers = parseChatBackendHeaders(fields.headers);
const models = fields.models.map(model => ({
id: model.id,
name: model.name,
tools: model.tools,
contextWindow: optionalTokenLimit(model.contextWindow),
maxOutputTokens: optionalTokenLimit(model.maxOutputTokens),
}));
return validateChatBackendDraft({ ...fields, headers, models, allowLocalHttp });
}

export function backendConnectionFromFields(
fields: BackendFormFields,
allowLocalHttp: boolean
): BackendConnection {
const headers = parseChatBackendHeaders(fields.headers);
return validateBackendConnection({ ...fields, headers, allowLocalHttp });
}
Loading