mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465dba4b3f | ||
|
|
4f857b0abb | ||
|
|
20aa08ef04 | ||
|
|
9a63561db8 | ||
|
|
7959285ad8 | ||
|
|
26e1741059 | ||
|
|
2ed885eb11 | ||
|
|
07fcf35276 | ||
|
|
4efdb07dae | ||
|
|
7feda3dad3 | ||
|
|
f08ad574d3 | ||
|
|
1bfc25a860 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "185,599",
|
||||
"message": "189,482",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"total_clones": 189482,
|
||||
"last_updated": "2026-08-13T07:22:13Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -137,6 +137,9 @@
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076
|
||||
"2026-08-09": 1076,
|
||||
"2026-08-10": 1060,
|
||||
"2026-08-11": 2182,
|
||||
"2026-08-12": 641
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,104 @@ jobs:
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
# Validate Apple credentials BEFORE the expensive work. Notarization is
|
||||
# the very last thing `tauri-action` does, so a bad credential or a
|
||||
# lapsed account agreement previously surfaced ~10 minutes in — after the
|
||||
# Rust toolchain, npm install, two Ollama sidecar downloads and a
|
||||
# universal cargo build — as a single opaque line:
|
||||
#
|
||||
# failed to bundle project: failed codesign application: failed to
|
||||
# notarize app: Error: HTTP status code: 403. ...
|
||||
#
|
||||
# `notarytool history` is a read-only call (it submits nothing) that
|
||||
# exercises the identical auth path, so every credential/account failure
|
||||
# mode reaches us here first, in seconds, with the specific cause named.
|
||||
# `xcrun` is preinstalled on macOS runners, hence placement before the
|
||||
# toolchain steps rather than next to "Configure Apple signing".
|
||||
- name: Preflight Apple notarization credentials
|
||||
if: matrix.platform == 'macos-14'
|
||||
env:
|
||||
CERT: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
A_ID: ${{ secrets.APPLE_ID }}
|
||||
A_PASS: ${{ secrets.APPLE_PASSWORD }}
|
||||
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Mirror the skip logic in "Configure Apple signing": without a
|
||||
# certificate the build is unsigned and never notarizes, so there is
|
||||
# nothing to preflight. Tag builds still hard-fail there.
|
||||
if [ -z "$CERT" ]; then
|
||||
echo "No Apple certificate configured; skipping notarization preflight."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
missing=""
|
||||
[ -z "$A_ID" ] && missing="$missing APPLE_ID"
|
||||
[ -z "$A_PASS" ] && missing="$missing APPLE_PASSWORD"
|
||||
[ -z "$A_TEAM" ] && missing="$missing APPLE_TEAM_ID"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::APPLE_CERTIFICATE is set but notarization secrets are missing:$missing"
|
||||
echo "::error::Signing would succeed and notarization would then fail. Set them or clear APPLE_CERTIFICATE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Retry only to absorb transient network faults. Credential and
|
||||
# account errors are deterministic, so we classify and exit on the
|
||||
# first definitive answer rather than retrying into the same wall.
|
||||
attempt=1
|
||||
while [ "$attempt" -le 3 ]; do
|
||||
out=$(xcrun notarytool history \
|
||||
--apple-id "$A_ID" \
|
||||
--team-id "$A_TEAM" \
|
||||
--password "$A_PASS" \
|
||||
--output-format json 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "Apple notarization preflight OK — credentials valid, team reachable, agreements in effect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$out" in
|
||||
*"Invalid credentials"*|*"401"*)
|
||||
echo "::error::Apple notarization preflight failed: invalid credentials (HTTP 401)."
|
||||
echo "::error::APPLE_PASSWORD must be an app-specific password from appleid.apple.com,"
|
||||
echo "::error::generated while signed in as the SAME Apple ID as APPLE_ID. A regular"
|
||||
echo "::error::Apple ID password will not work, and a password minted under a different"
|
||||
echo "::error::Apple ID authenticates as that other account."
|
||||
exit 1
|
||||
;;
|
||||
*"Invalid or inaccessible developer team ID"*)
|
||||
echo "::error::Apple notarization preflight failed: APPLE_ID is not a member of team APPLE_TEAM_ID (HTTP 403)."
|
||||
echo "::error::The Team ID must match the signing certificate. Read it from the cert's"
|
||||
echo "::error::subject, where it appears as: Developer ID Application: NAME (TEAMID)."
|
||||
echo "::error::If you belong to several teams, confirm APPLE_ID is a member of this one."
|
||||
exit 1
|
||||
;;
|
||||
*"required agreement"*|*"agreement"*)
|
||||
echo "::error::Apple notarization preflight failed: the team has no in-effect agreement (HTTP 403)."
|
||||
echo "::error::Apple reissues the Developer Program License Agreement periodically and"
|
||||
echo "::error::notarization is refused until it is accepted. ONLY THE ACCOUNT HOLDER can"
|
||||
echo "::error::accept it — team Admins cannot. Sign in to the account that owns this team:"
|
||||
echo "::error:: 1. https://developer.apple.com/account -> review any pending agreement"
|
||||
echo "::error:: 2. App Store Connect -> Business -> accept anything pending there too"
|
||||
echo "::error::Certificates stay valid while this is outstanding, so signing still works."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Preflight attempt ${attempt}/3 failed with a non-credential error."
|
||||
echo "$out" | tail -5
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le 3 ] && sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Apple notarization preflight failed after 3 attempts. Last output:"
|
||||
echo "$out" | tail -20
|
||||
exit 1
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
|
||||
@@ -31,7 +31,6 @@ export default function App() {
|
||||
const prevModelRef = useRef<string>('');
|
||||
const setModels = useAppStore((s) => s.setModels);
|
||||
const setModelsLoading = useAppStore((s) => s.setModelsLoading);
|
||||
const setSelectedModel = useAppStore((s) => s.setSelectedModel);
|
||||
const selectedModel = useAppStore((s) => s.selectedModel);
|
||||
const setServerInfo = useAppStore((s) => s.setServerInfo);
|
||||
const setSavings = useAppStore((s) => s.setSavings);
|
||||
@@ -70,7 +69,6 @@ export default function App() {
|
||||
fetchModels()
|
||||
.then((m) => {
|
||||
setModels(m);
|
||||
if (!selectedModel && m.length > 0) setSelectedModel(m[0].id);
|
||||
})
|
||||
.catch(() => setModels([]))
|
||||
.finally(() => setModelsLoading(false));
|
||||
|
||||
@@ -15,6 +15,7 @@ function getGreeting(): string {
|
||||
}
|
||||
|
||||
export function ChatArea() {
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const streamState = useAppStore((s) => s.streamState);
|
||||
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
|
||||
@@ -24,6 +25,8 @@ export function ChatArea() {
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -38,14 +41,14 @@ export function ChatArea() {
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
if (isCurrentChatStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
wasStreaming.current = isCurrentChatStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
}, [messages, currentStreamContent, isCurrentChatStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
@@ -66,7 +69,7 @@ export function ChatArea() {
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
|
||||
|
||||
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
|
||||
|
||||
@@ -174,12 +177,12 @@ export function ChatArea() {
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLive={isLastAssistant && streamState.isStreaming}
|
||||
isLive={isLastAssistant && isCurrentChatStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
if (!streamState.isStreaming || streamState.content !== '') return null;
|
||||
if (!isCurrentChatStreaming || streamState.content !== '') return null;
|
||||
// For research messages the ResearchTimeline handles its own
|
||||
// pre-content loading state — suppress the generic dots.
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
@@ -96,6 +96,7 @@ export function InputArea() {
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
|
||||
const {
|
||||
state: speechState,
|
||||
@@ -226,6 +227,7 @@ export function InputArea() {
|
||||
let ttftMs: number | undefined;
|
||||
|
||||
setStreamState({
|
||||
conversationId: convId,
|
||||
isStreaming: true,
|
||||
phase: deepResearch ? 'Researching...' : 'Generating...',
|
||||
elapsedMs: 0,
|
||||
@@ -602,7 +604,7 @@ export function InputArea() {
|
||||
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
|
||||
disabled={streamState.isStreaming || modelLoading}
|
||||
/>
|
||||
{streamState.isStreaming ? (
|
||||
{isCurrentChatStreaming ? (
|
||||
<button
|
||||
onClick={stopStreaming}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
|
||||
@@ -621,7 +623,7 @@ export function InputArea() {
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || modelLoading || !selectedModel}
|
||||
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
|
||||
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SetupStatus,
|
||||
} from '../lib/api';
|
||||
import { useAppStore } from '../lib/store';
|
||||
import { isEmbedOnlyModel } from '../lib/model-capabilities';
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'ollama_ready', label: 'Inference Engine', icon: Cpu, detail: 'Starting Ollama...' },
|
||||
@@ -91,12 +92,14 @@ export function SetupScreen({ onReady }: { onReady: () => void }) {
|
||||
fetchRecommendedModel().catch(() => ({ model: '', reason: '' })),
|
||||
]);
|
||||
const store = useAppStore.getState();
|
||||
const hadSelection = !!store.selectedModel;
|
||||
store.setModels(models);
|
||||
store.setModelsLoading(false);
|
||||
const recommended = rec.model && models.some((m) => m.id === rec.model)
|
||||
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
|
||||
const recommended = rec.model && chatModels.some((m) => m.id === rec.model)
|
||||
? rec.model
|
||||
: models[0]?.id || '';
|
||||
if (recommended && !store.selectedModel) {
|
||||
: chatModels[0]?.id || '';
|
||||
if (recommended && !hadSelection) {
|
||||
store.setSelectedModel(recommended);
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const streamingConversationId = useAppStore((s) =>
|
||||
s.streamState.isStreaming ? s.streamState.conversationId : null,
|
||||
);
|
||||
const selectConversation = useAppStore((s) => s.selectConversation);
|
||||
const deleteConversation = useAppStore((s) => s.deleteConversation);
|
||||
|
||||
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{filtered.map((conv) => {
|
||||
const isActive = conv.id === activeId;
|
||||
const isStreaming = conv.id === streamingConversationId;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
e.stopPropagation();
|
||||
deleteConversation(conv.id);
|
||||
}}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
disabled={isStreaming}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
|
||||
}}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
|
||||
title="Delete conversation"
|
||||
title={
|
||||
isStreaming
|
||||
? 'Stop generating before deleting this conversation'
|
||||
: 'Delete conversation'
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isEmbedOnlyModel } from './model-capabilities';
|
||||
|
||||
describe('isEmbedOnlyModel', () => {
|
||||
it.each([
|
||||
'nomic-embed-text',
|
||||
'mxbai-embed-large',
|
||||
'text-embedding-3-small',
|
||||
'all-minilm:latest',
|
||||
'hf.co/BAAI/bge-m3:latest',
|
||||
])('classifies %s as embedding-only', (modelId) => {
|
||||
expect(isEmbedOnlyModel(modelId)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['qwen3.5:4b', 'codegemma:7b'])('keeps %s available for chat', (modelId) => {
|
||||
expect(isEmbedOnlyModel(modelId)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const EMBEDDING_MODEL_PREFIXES = [
|
||||
'all-minilm',
|
||||
'bge-',
|
||||
'bge_',
|
||||
'e5-',
|
||||
'e5_',
|
||||
'gte-',
|
||||
'gte_',
|
||||
'jina-embeddings',
|
||||
'nomic-bert',
|
||||
'sentence-transformers',
|
||||
];
|
||||
|
||||
export function isEmbedOnlyModel(modelId: string): boolean {
|
||||
const name = (modelId || '').trim().toLowerCase();
|
||||
const leaf = name.slice(name.lastIndexOf('/') + 1).split(':')[0];
|
||||
return (
|
||||
leaf.includes('embed') ||
|
||||
leaf.includes('minilm') ||
|
||||
EMBEDDING_MODEL_PREFIXES.some((prefix) => leaf.startsWith(prefix))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ModelInfo } from '../types';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
const model = (id: string): ModelInfo => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'openjarvis',
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
describe('setModels', () => {
|
||||
it('does not select an embedding-only model', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
|
||||
useAppStore.getState().setModels([model('nomic-embed-text')]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('');
|
||||
});
|
||||
|
||||
it('clears a missing selection when no chat fallback exists', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
useAppStore.getState().setSelectedModel('deleted-chat-model');
|
||||
|
||||
useAppStore.getState().setModels([model('nomic-embed-text')]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('');
|
||||
});
|
||||
|
||||
it('replaces an embedding selection with an available chat model', async () => {
|
||||
const { useAppStore } = await import('./store');
|
||||
useAppStore.getState().setSelectedModel('all-minilm:latest');
|
||||
|
||||
useAppStore.getState().setModels([
|
||||
model('all-minilm:latest'),
|
||||
model('qwen3.5:4b'),
|
||||
]);
|
||||
|
||||
expect(useAppStore.getState().selectedModel).toBe('qwen3.5:4b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
async function freshStore() {
|
||||
return (await import('./store')).useAppStore;
|
||||
}
|
||||
|
||||
describe('conversation stream ownership', () => {
|
||||
it('persists background stream updates without replacing the active messages', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
store.getState().addMessage(sourceId, {
|
||||
id: 'assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
content: 'streamed response',
|
||||
});
|
||||
store.getState().updateLastAssistant(sourceId, 'streamed response');
|
||||
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
expect(store.getState().messages).toEqual([]);
|
||||
|
||||
store.getState().selectConversation(sourceId);
|
||||
expect(store.getState().messages).toHaveLength(1);
|
||||
expect(store.getState().messages[0].content).toBe('streamed response');
|
||||
});
|
||||
|
||||
it('keeps the stream-owning conversation until generation stops', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).toContain(sourceId);
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
|
||||
store.getState().resetStream();
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).not.toContain(sourceId);
|
||||
});
|
||||
});
|
||||
+46
-12
@@ -15,6 +15,7 @@ import type {
|
||||
TokenUsage,
|
||||
} from '../types';
|
||||
import type { ManagedAgent } from './api';
|
||||
import { isEmbedOnlyModel } from './model-capabilities';
|
||||
|
||||
export interface CachedConnector {
|
||||
connector_id: string;
|
||||
@@ -110,6 +111,7 @@ function saveSettings(settings: Settings): void {
|
||||
// ── Store ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INITIAL_STREAM: StreamState = {
|
||||
conversationId: null,
|
||||
isStreaming: false,
|
||||
phase: '',
|
||||
elapsedMs: 0,
|
||||
@@ -351,6 +353,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const streamState = get().streamState;
|
||||
if (streamState.isStreaming && streamState.conversationId === id) return;
|
||||
|
||||
const store = loadConversations();
|
||||
delete store.conversations[id];
|
||||
if (store.activeId === id) {
|
||||
@@ -393,12 +398,14 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
(message.content.length > 50 ? '...' : '');
|
||||
}
|
||||
saveConversations(store);
|
||||
set({
|
||||
messages: [...conv.messages],
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
});
|
||||
const conversations = Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
);
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages], conversations });
|
||||
} else {
|
||||
set({ conversations });
|
||||
}
|
||||
},
|
||||
|
||||
updateLastAssistant: (
|
||||
@@ -425,7 +432,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
if (researchSources) lastMsg.researchSources = researchSources;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -444,11 +453,36 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
// ── Models & server ────────────────────────────────────────────
|
||||
|
||||
setModels: (models: ModelInfo[]) =>
|
||||
set((state) =>
|
||||
!state.selectedModel && models.length > 0
|
||||
? { models, selectedModel: models[0].id }
|
||||
: { models },
|
||||
),
|
||||
set((state) => {
|
||||
// Ollama returns embed-only models (e.g. nomic-embed-text) in the
|
||||
// same list as chat models. Auto-picking models[0] selected the
|
||||
// embedder and every chat failed with HTTP 400 "does not support
|
||||
// chat". Prefer a real chat model for selection / fallback.
|
||||
const chatModels = models.filter((m) => !isEmbedOnlyModel(m.id));
|
||||
const preferred =
|
||||
(state.settings.defaultModel &&
|
||||
chatModels.some((m) => m.id === state.settings.defaultModel) &&
|
||||
state.settings.defaultModel) ||
|
||||
chatModels[0]?.id ||
|
||||
models.find((m) => !isEmbedOnlyModel(m.id))?.id ||
|
||||
'';
|
||||
|
||||
const currentIsBad =
|
||||
!!state.selectedModel && isEmbedOnlyModel(state.selectedModel);
|
||||
const currentMissing =
|
||||
!!state.selectedModel &&
|
||||
!models.some((m) => m.id === state.selectedModel);
|
||||
|
||||
if (!state.selectedModel || currentIsBad || currentMissing) {
|
||||
// Prefer a real chat model. If none exist, clear a bad/missing
|
||||
// selection rather than keeping an embed-only id that 400s on chat.
|
||||
return {
|
||||
models,
|
||||
selectedModel: preferred,
|
||||
};
|
||||
}
|
||||
return { models };
|
||||
}),
|
||||
setModelsLoading: (loading: boolean) => set({ modelsLoading: loading }),
|
||||
setSelectedModel: (model: string) => set({ selectedModel: model }),
|
||||
setServerInfo: (info: ServerInfo | null) => set({ serverInfo: info }),
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface ConversationStore {
|
||||
// --- Stream State ---
|
||||
|
||||
export interface StreamState {
|
||||
conversationId: string | null;
|
||||
isStreaming: boolean;
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
|
||||
@@ -11,7 +11,9 @@ Three install paths are supported today:
|
||||
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
|
||||
cloned repo). The package's ``__file__`` is inside a working tree
|
||||
with a ``.git`` directory at the repo root. Upgrade with
|
||||
``git pull && uv sync`` from the checkout.
|
||||
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
|
||||
important here: a bare ``uv sync`` removes packages installed by extras or
|
||||
dependency groups that are not part of the base project.
|
||||
|
||||
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
|
||||
confidence we fall back to the PyPI command — that's the most common
|
||||
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
|
||||
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
|
||||
return InstallInfo(
|
||||
kind="editable-git",
|
||||
upgrade_command=f"cd {candidate} && git pull && uv sync",
|
||||
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
|
||||
repo_root=candidate,
|
||||
)
|
||||
if candidate.parent == candidate:
|
||||
|
||||
@@ -158,7 +158,7 @@ def _show_toml_config(console: Console, config_path: Path) -> None:
|
||||
console.print(f"[dim]Loading config from: {config_path}[/dim]")
|
||||
|
||||
if config_path.exists():
|
||||
config_content = config_path.read_text()
|
||||
config_content = config_path.read_text(encoding="utf-8")
|
||||
syntax = Syntax(config_content, "toml", theme="monokai", line_numbers=True)
|
||||
console.print(Panel(syntax, title="Config File", border_style="cyan"))
|
||||
else:
|
||||
@@ -170,7 +170,7 @@ def _show_json_config(console: Console, config_path: Path) -> None:
|
||||
console.print(f"[dim]Loading config from: {config_path}[/dim]")
|
||||
|
||||
if config_path.exists():
|
||||
config_content = config_path.read_text()
|
||||
config_content = config_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
@@ -375,7 +375,7 @@ def set_config(key: str, value: str) -> None:
|
||||
os.environ.get("OPENJARVIS_CONFIG", DEFAULT_CONFIG_DIR / "config.toml")
|
||||
)
|
||||
if config_path.exists():
|
||||
doc = tomlkit.parse(config_path.read_text())
|
||||
doc = tomlkit.parse(config_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
doc = tomlkit.document()
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -390,7 +390,7 @@ def set_config(key: str, value: str) -> None:
|
||||
current[parts[-1]] = typed_value
|
||||
|
||||
# Write back
|
||||
config_path.write_text(tomlkit.dumps(doc))
|
||||
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
|
||||
|
||||
console.print(f"[green]Set[/green] {key} = {value!r}")
|
||||
|
||||
|
||||
@@ -17,18 +17,64 @@ _PID_FILE = DEFAULT_CONFIG_DIR / "server.pid"
|
||||
_LOG_FILE = DEFAULT_CONFIG_DIR / "server.log"
|
||||
|
||||
|
||||
def _pid_alive(pid: int) -> bool:
|
||||
"""Return whether *pid* identifies a running process without signaling it."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
|
||||
if os.name == "nt":
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
error_invalid_parameter = 87
|
||||
synchronize = 0x00100000
|
||||
wait_object_0 = 0x00000000
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
||||
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
||||
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
|
||||
handle = kernel32.OpenProcess(synchronize, False, pid)
|
||||
if not handle:
|
||||
# OpenProcess reports ERROR_INVALID_PARAMETER when the PID does not
|
||||
# exist. For access-denied and other inconclusive failures, retain
|
||||
# the PID file rather than declaring a potentially live daemon dead.
|
||||
return ctypes.get_last_error() != error_invalid_parameter
|
||||
|
||||
try:
|
||||
wait_result = kernel32.WaitForSingleObject(handle, 0)
|
||||
# WAIT_OBJECT_0 proves the process exited. WAIT_TIMEOUT proves it
|
||||
# is live; unexpected failures are inconclusive, so retain the PID.
|
||||
return wait_result != wait_object_0
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _read_pid() -> int | None:
|
||||
"""Read PID from pid file, return None if not found or stale."""
|
||||
if not _PID_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
pid = int(_PID_FILE.read_text().strip())
|
||||
# Check if process is still running
|
||||
os.kill(pid, 0)
|
||||
return pid
|
||||
except (ValueError, OSError):
|
||||
except (OSError, ValueError):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
if not _pid_alive(pid):
|
||||
_PID_FILE.unlink(missing_ok=True)
|
||||
return None
|
||||
return pid
|
||||
|
||||
|
||||
def _write_pid(pid: int) -> None:
|
||||
@@ -127,14 +173,13 @@ def stop() -> None:
|
||||
# Wait up to 10 seconds for graceful shutdown
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
if not _pid_alive(pid):
|
||||
break
|
||||
else:
|
||||
# Force kill if still running
|
||||
# SIGKILL is POSIX-only. On Windows SIGTERM already maps to
|
||||
# TerminateProcess, so repeating it is the available escalation.
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
os.kill(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
|
||||
@@ -344,7 +344,9 @@ def init(
|
||||
console.print(f" Looked in: {examples_dir}")
|
||||
raise SystemExit(1)
|
||||
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
DEFAULT_CONFIG_PATH.write_text(preset_path.read_text())
|
||||
DEFAULT_CONFIG_PATH.write_text(
|
||||
preset_path.read_text(encoding="utf-8"), encoding="utf-8"
|
||||
)
|
||||
console.print(
|
||||
f"[green]Preset '{preset}' installed to {DEFAULT_CONFIG_PATH}[/green]"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
|
||||
|
||||
- PyPI installs get ``pip install --upgrade openjarvis``.
|
||||
- uv-tool installs get ``uv tool upgrade openjarvis``.
|
||||
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
|
||||
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
|
||||
The inexact sync preserves packages previously installed through extras and
|
||||
dependency groups.
|
||||
|
||||
The detection logic is shared with the post-command "new version
|
||||
available" hint in ``_version_check.py`` so both surfaces stay in sync.
|
||||
|
||||
@@ -9,6 +9,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
@@ -1305,6 +1306,160 @@ class CloudEngine(InferenceEngine):
|
||||
if chunk.text:
|
||||
yield chunk.text
|
||||
|
||||
async def _stream_full_google(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[StreamChunk]:
|
||||
"""Stream Google text and function-call parts as full chunks."""
|
||||
if self._google_client is None:
|
||||
raise EngineConnectionError("Google client not available")
|
||||
|
||||
system_text = ""
|
||||
contents: List[Dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if message.role.value == "system":
|
||||
system_text = message.content
|
||||
elif message.role.value == "tool":
|
||||
function_response = {
|
||||
"function_response": {
|
||||
"name": message.name or "unknown",
|
||||
"response": {"result": message.content},
|
||||
}
|
||||
}
|
||||
if (
|
||||
contents
|
||||
and contents[-1]["role"] == "user"
|
||||
and contents[-1]["parts"]
|
||||
and "function_response" in contents[-1]["parts"][-1]
|
||||
):
|
||||
contents[-1]["parts"].append(function_response)
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [function_response]})
|
||||
elif message.role.value == "assistant" and message.tool_calls:
|
||||
parts: List[Dict[str, Any]] = []
|
||||
if message.content:
|
||||
parts.append({"text": message.content})
|
||||
for tool_call in message.tool_calls:
|
||||
args = tool_call.arguments
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {"input": args}
|
||||
function_call_part: Dict[str, Any] = {
|
||||
"function_call": {
|
||||
"name": tool_call.name,
|
||||
"args": args if isinstance(args, dict) else {},
|
||||
}
|
||||
}
|
||||
signature = self._thought_sigs.get(tool_call.id)
|
||||
if signature is not None:
|
||||
function_call_part["thought_signature"] = signature
|
||||
parts.append(function_call_part)
|
||||
contents.append({"role": "model", "parts": parts})
|
||||
elif message.role.value == "assistant":
|
||||
contents.append({"role": "model", "parts": [{"text": message.content}]})
|
||||
else:
|
||||
contents.append({"role": "user", "parts": [{"text": message.content}]})
|
||||
|
||||
from google.genai import types as genai_types
|
||||
|
||||
config = genai_types.GenerateContentConfig(
|
||||
temperature=temperature,
|
||||
max_output_tokens=max_tokens,
|
||||
)
|
||||
if system_text:
|
||||
config.system_instruction = system_text
|
||||
|
||||
tools = kwargs.pop("tools", None)
|
||||
if tools:
|
||||
config.tools = [{"function_declarations": _convert_tools_to_google(tools)}]
|
||||
|
||||
tool_call_count = 0
|
||||
stream_id = uuid.uuid4().hex
|
||||
final_usage: Dict[str, Any] | None = None
|
||||
for chunk in self._google_client.models.generate_content_stream(
|
||||
model=model,
|
||||
contents=contents,
|
||||
config=config,
|
||||
):
|
||||
usage_metadata = getattr(chunk, "usage_metadata", None)
|
||||
if usage_metadata is not None:
|
||||
prompt_tokens = getattr(usage_metadata, "prompt_token_count", 0) or 0
|
||||
completion_tokens = (
|
||||
getattr(usage_metadata, "candidates_token_count", 0) or 0
|
||||
)
|
||||
final_usage = {
|
||||
"prompt_tokens": prompt_tokens,
|
||||
"completion_tokens": completion_tokens,
|
||||
"total_tokens": prompt_tokens + completion_tokens,
|
||||
}
|
||||
|
||||
candidates = getattr(chunk, "candidates", None)
|
||||
parts = []
|
||||
if candidates:
|
||||
parts = getattr(candidates[0].content, "parts", []) or []
|
||||
|
||||
if parts:
|
||||
text_found = False
|
||||
calls: List[Dict[str, Any]] = []
|
||||
for part in parts:
|
||||
text = getattr(part, "text", None)
|
||||
if text:
|
||||
text_found = True
|
||||
yield StreamChunk(content=text)
|
||||
|
||||
function_call = getattr(part, "function_call", None)
|
||||
if function_call:
|
||||
name = getattr(function_call, "name", "")
|
||||
raw_args = getattr(function_call, "args", {})
|
||||
args = dict(raw_args) if hasattr(raw_args, "items") else {}
|
||||
# Gemini emits complete function-call parts, so each part is
|
||||
# a distinct invocation. The same function may legitimately
|
||||
# be called more than once in a parallel response.
|
||||
tool_index = tool_call_count
|
||||
# The engine is shared across server requests, and saved
|
||||
# thought signatures are keyed by tool-call ID. Include a
|
||||
# per-stream nonce so concurrent conversations cannot
|
||||
# overwrite each other's signatures.
|
||||
tool_id = f"google_{stream_id}_{tool_index}"
|
||||
tool_call_count += 1
|
||||
tool_call = {
|
||||
"index": tool_index,
|
||||
"id": tool_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"arguments": json.dumps(args),
|
||||
},
|
||||
}
|
||||
calls.append(tool_call)
|
||||
signature = getattr(part, "thought_signature", None)
|
||||
if signature is not None:
|
||||
tool_call["thought_signature"] = signature
|
||||
self._thought_sigs[tool_id] = signature
|
||||
if calls:
|
||||
yield StreamChunk(tool_calls=calls)
|
||||
if text_found:
|
||||
continue
|
||||
|
||||
try:
|
||||
text = chunk.text
|
||||
except (AttributeError, ValueError):
|
||||
text = None
|
||||
if text:
|
||||
yield StreamChunk(content=text)
|
||||
|
||||
yield StreamChunk(
|
||||
finish_reason="tool_calls" if tool_call_count else "stop",
|
||||
usage=final_usage,
|
||||
)
|
||||
|
||||
async def _stream_openrouter(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
@@ -1600,7 +1755,7 @@ class CloudEngine(InferenceEngine):
|
||||
async for chunk in self._stream_full_anthropic(messages, **kw):
|
||||
yield chunk
|
||||
elif _is_google_model(model):
|
||||
async for chunk in super().stream_full(messages, **kw):
|
||||
async for chunk in self._stream_full_google(messages, **kw):
|
||||
yield chunk
|
||||
else:
|
||||
async for chunk in self._stream_full_openai(messages, **kw):
|
||||
|
||||
@@ -20,6 +20,7 @@ from openjarvis.agents.tool_resolver import (
|
||||
from openjarvis.agents.tool_resolver import (
|
||||
ensure_registries_populated as _ensure_registries_populated,
|
||||
)
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
|
||||
try:
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -332,16 +333,29 @@ _CLOUD_PREFIXES = ("gpt-", "claude-", "gemini-", "o1-", "o3-", "o4-")
|
||||
def _pick_recommended_model(
|
||||
model_ids: list[str],
|
||||
) -> dict[str, str]:
|
||||
"""Pick the second-largest local model from a list."""
|
||||
local = [m for m in model_ids if not any(m.startswith(p) for p in _CLOUD_PREFIXES)]
|
||||
"""Pick the second-largest local *chat* model from a list.
|
||||
|
||||
Embedding-only models (nomic-embed-text, etc.) are excluded — they return
|
||||
HTTP 400 "does not support chat" when used as the generation model.
|
||||
"""
|
||||
local = [
|
||||
m
|
||||
for m in model_ids
|
||||
if not any(m.startswith(p) for p in _CLOUD_PREFIXES)
|
||||
and not is_embed_only_model(m)
|
||||
]
|
||||
if not local:
|
||||
# Fall back to any non-cloud model, still skipping embedders.
|
||||
local = [m for m in model_ids if not is_embed_only_model(m)]
|
||||
if not local:
|
||||
# Never recommend an embed-only model — chat would 400.
|
||||
return {
|
||||
"model": model_ids[0] if model_ids else "",
|
||||
"reason": "Only model available",
|
||||
"model": "",
|
||||
"reason": "No local chat model available",
|
||||
}
|
||||
sized = sorted(local, key=_parse_param_count, reverse=True)
|
||||
if len(sized) == 1:
|
||||
return {"model": sized[0], "reason": "Only local model available"}
|
||||
return {"model": sized[0], "reason": "Only local chat model available"}
|
||||
pick = sized[1] # second-largest
|
||||
params = _parse_param_count(pick)
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Model capability helpers shared by server model-selection routes."""
|
||||
|
||||
_EMBEDDING_MODEL_PREFIXES = (
|
||||
"all-minilm",
|
||||
"bge-",
|
||||
"bge_",
|
||||
"e5-",
|
||||
"e5_",
|
||||
"gte-",
|
||||
"gte_",
|
||||
"jina-embeddings",
|
||||
"nomic-bert",
|
||||
"sentence-transformers",
|
||||
)
|
||||
|
||||
|
||||
def is_embed_only_model(model_name: str) -> bool:
|
||||
"""Return whether a model identifier denotes a non-chat embedder.
|
||||
|
||||
Ollama does not expose capabilities through its model-list response, so
|
||||
model selection needs a conservative name-based guard. Most embedding
|
||||
models contain ``embed``; the explicit prefixes cover common families
|
||||
such as MiniLM, BGE, E5, and GTE whose names do not.
|
||||
"""
|
||||
name = (model_name or "").strip().lower()
|
||||
leaf = name.rsplit("/", 1)[-1].split(":", 1)[0]
|
||||
return (
|
||||
"embed" in leaf
|
||||
or "minilm" in leaf
|
||||
or leaf.startswith(_EMBEDDING_MODEL_PREFIXES)
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["is_embed_only_model"]
|
||||
@@ -12,6 +12,7 @@ from fastapi.responses import StreamingResponse
|
||||
|
||||
from openjarvis.core.paths import get_config_dir
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
from openjarvis.server.models import (
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionRequest,
|
||||
@@ -892,6 +893,11 @@ async def list_models(request: Request) -> ModelListResponse:
|
||||
if not model_ids:
|
||||
model_ids = await list_local_models()
|
||||
|
||||
# Keep embed-only models out of the chat model picker. They still work for
|
||||
# memory/retrieval via the embedder path; putting them in /v1/models made
|
||||
# the UI auto-select nomic-embed-text and fail every generation with 400.
|
||||
model_ids = [m for m in model_ids if not is_embed_only_model(m)]
|
||||
|
||||
return ModelListResponse(
|
||||
data=[
|
||||
ModelObject(
|
||||
|
||||
@@ -138,6 +138,38 @@ class TestCLI:
|
||||
content = config_path.read_text()
|
||||
assert "[engine]" in content
|
||||
|
||||
def test_init_preset_uses_utf8_for_config_copy(self, tmp_path: Path) -> None:
|
||||
"""Preset installation reads and writes shipped TOML as UTF-8."""
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
original_read_text = Path.read_text
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def read_text(path: Path, *args: object, **kwargs: object) -> str:
|
||||
if path.name == "chat-simple.toml":
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
|
||||
if path == config_path:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_write_text(path, data, *args, **kwargs)
|
||||
|
||||
with (
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
|
||||
mock.patch.object(
|
||||
Path, "write_text", autospec=True, side_effect=write_text
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--preset", "chat-simple"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "lightweight conversational AI" in config_path.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
class TestStartupResilience:
|
||||
"""Importing the CLI must not force heavy/native deps (#404, #309).
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
@@ -109,18 +110,34 @@ temperature = 0.7
|
||||
except json.JSONDecodeError:
|
||||
pytest.fail(f"Output is not valid JSON: {result.output}")
|
||||
|
||||
def test_config_show_toml_displays_raw_content(self, tmp_path: Path) -> None:
|
||||
"""Test that config show toml displays the raw TOML content."""
|
||||
@pytest.mark.parametrize("output_format", ["toml", "json"])
|
||||
def test_config_show_uses_utf8_for_config_file(
|
||||
self, tmp_path: Path, output_format: str
|
||||
) -> None:
|
||||
"""Test that config show reads UTF-8 config files explicitly."""
|
||||
# Create a temporary config file
|
||||
config_file = tmp_path / "test_config.toml"
|
||||
config_file.write_text('[engine]\ndefault = "ollama"\n')
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli, ["config", "show", "toml", "--path", str(config_file)]
|
||||
config_file.write_text(
|
||||
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def read_text(path: Path, *args: object, **kwargs: object) -> str:
|
||||
if path == config_file:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["config", "show", output_format, "--path", str(config_file)]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "[engine]" in result.output
|
||||
if output_format == "toml":
|
||||
assert "[engine]" in result.output
|
||||
else:
|
||||
assert '"engine"' in result.output
|
||||
assert "ollama" in result.output
|
||||
|
||||
def test_config_show_json_displays_parsed_content(self, tmp_path: Path) -> None:
|
||||
|
||||
@@ -60,6 +60,42 @@ class TestConfigSet:
|
||||
assert "vllm" in content
|
||||
assert "qwen2.5:3b" in content
|
||||
|
||||
def test_set_uses_utf8_for_existing_config(self, tmp_path: Path) -> None:
|
||||
"""config set preserves a UTF-8 config regardless of the system locale."""
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text(
|
||||
'# Preset comment — stored as UTF-8\n[engine]\ndefault = "ollama"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
original_read_text = Path.read_text
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def read_text(path: Path, *args: object, **kwargs: object) -> str:
|
||||
if path == config_file:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_read_text(path, *args, **kwargs)
|
||||
|
||||
def write_text(path: Path, data: str, *args: object, **kwargs: object) -> int:
|
||||
if path == config_file:
|
||||
assert kwargs.get("encoding") == "utf-8"
|
||||
return original_write_text(path, data, *args, **kwargs)
|
||||
|
||||
with (
|
||||
mock.patch.dict(os.environ, {"OPENJARVIS_CONFIG": str(config_file)}),
|
||||
mock.patch.object(Path, "read_text", autospec=True, side_effect=read_text),
|
||||
mock.patch.object(
|
||||
Path, "write_text", autospec=True, side_effect=write_text
|
||||
),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["config", "set", "engine.default", "vllm"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
assert "Preset comment — stored as UTF-8" in content
|
||||
assert "vllm" in content
|
||||
|
||||
def test_set_invalid_key_rejected(self, tmp_path: Path) -> None:
|
||||
"""config set rejects unknown keys."""
|
||||
config_file = tmp_path / "config.toml"
|
||||
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from openjarvis.cli import cli
|
||||
from openjarvis.cli.daemon_cmd import _read_pid, _write_pid
|
||||
from openjarvis.cli.daemon_cmd import _pid_alive, _read_pid, _write_pid
|
||||
|
||||
|
||||
class TestDaemonCommands:
|
||||
@@ -45,12 +48,12 @@ class TestDaemonCommands:
|
||||
assert _read_pid() is None
|
||||
|
||||
def test_write_and_read_pid(self, tmp_path: Path) -> None:
|
||||
"""Write a PID, then read it back (mock os.kill to succeed)."""
|
||||
"""Write a PID, then read it back with a successful liveness probe."""
|
||||
pid_file = tmp_path / "server.pid"
|
||||
with (
|
||||
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
|
||||
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch("os.kill", return_value=None),
|
||||
patch("openjarvis.cli.daemon_cmd._pid_alive", return_value=True),
|
||||
):
|
||||
_write_pid(12345)
|
||||
assert pid_file.exists()
|
||||
@@ -82,6 +85,53 @@ class TestDaemonCommands:
|
||||
assert "already running" in result.output
|
||||
|
||||
|
||||
class TestPidLiveness:
|
||||
"""Regression coverage for Windows-safe PID liveness checks."""
|
||||
|
||||
def test_pid_alive_current_process(self) -> None:
|
||||
assert _pid_alive(os.getpid()) is True
|
||||
|
||||
def test_pid_alive_nonpositive(self) -> None:
|
||||
assert _pid_alive(0) is False
|
||||
assert _pid_alive(-1) is False
|
||||
|
||||
def test_pid_alive_dead_pid(self) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
|
||||
for _ in range(20):
|
||||
if not _pid_alive(proc.pid):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
assert _pid_alive(proc.pid) is False
|
||||
|
||||
def test_read_pid_stale_pid_returns_none(self, tmp_path: Path) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "pass"])
|
||||
proc.wait()
|
||||
pid_file = tmp_path / "server.pid"
|
||||
pid_file.write_text(str(proc.pid))
|
||||
|
||||
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
|
||||
assert _read_pid() is None
|
||||
|
||||
assert not pid_file.exists()
|
||||
|
||||
def test_read_pid_live_pid_returns_it(self, tmp_path: Path) -> None:
|
||||
proc = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(10)"])
|
||||
try:
|
||||
pid_file = tmp_path / "server.pid"
|
||||
pid_file.write_text(str(proc.pid))
|
||||
|
||||
with patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file):
|
||||
assert _read_pid() == proc.pid
|
||||
|
||||
assert pid_file.exists()
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
|
||||
|
||||
class TestDaemonDetachment:
|
||||
"""The spawned server must outlive the console that started it.
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_editable_git_install_detected(tmp_path, monkeypatch):
|
||||
info = detect_install()
|
||||
assert info.kind == "editable-git"
|
||||
assert "git pull" in info.upgrade_command
|
||||
assert "uv sync" in info.upgrade_command
|
||||
assert info.upgrade_command.endswith("uv sync --inexact")
|
||||
assert info.repo_root == repo
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def _mock_info(kind: str = "pypi") -> InstallInfo:
|
||||
upgrade_command={
|
||||
"pypi": "pip install --upgrade openjarvis",
|
||||
"uv-tool": "uv tool upgrade openjarvis",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync --inexact",
|
||||
"unknown": "pip install --upgrade openjarvis",
|
||||
}[kind],
|
||||
)
|
||||
@@ -90,6 +90,26 @@ def test_editable_git_uses_shell_true():
|
||||
assert kwargs.get("shell") is True
|
||||
|
||||
|
||||
def test_editable_git_preserves_extra_dependencies():
|
||||
"""The update sync must not remove packages from prior extras/groups."""
|
||||
mock_proc = MagicMock(returncode=0)
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.detect_install",
|
||||
return_value=_mock_info("editable-git"),
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run,
|
||||
):
|
||||
result = CliRunner().invoke(self_update, ["-y"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "uv sync --inexact" in result.output
|
||||
assert "uv sync --inexact" in mock_run.call_args.args[0]
|
||||
|
||||
|
||||
def test_failed_upgrade_propagates_exit_code():
|
||||
mock_proc = MagicMock(returncode=3)
|
||||
with (
|
||||
|
||||
@@ -3,6 +3,8 @@ and _prepare_anthropic_messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any, List
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -63,6 +65,36 @@ def _openai_tool_call_delta(
|
||||
return tc
|
||||
|
||||
|
||||
class _GoogleConfig:
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
|
||||
def _google_stream_chunk(
|
||||
*parts: Any,
|
||||
text: str | None = None,
|
||||
usage_metadata: Any = None,
|
||||
) -> Any:
|
||||
candidates = []
|
||||
if parts:
|
||||
candidates = [SimpleNamespace(content=SimpleNamespace(parts=list(parts)))]
|
||||
return SimpleNamespace(
|
||||
text=text,
|
||||
candidates=candidates,
|
||||
usage_metadata=usage_metadata,
|
||||
)
|
||||
|
||||
|
||||
def _google_types_modules() -> dict[str, ModuleType]:
|
||||
types = ModuleType("google.genai.types")
|
||||
types.GenerateContentConfig = _GoogleConfig
|
||||
genai = ModuleType("google.genai")
|
||||
genai.types = types
|
||||
google = ModuleType("google")
|
||||
google.genai = genai
|
||||
return {"google": google, "google.genai": genai, "google.genai.types": types}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stream_full_openai tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -413,6 +445,316 @@ def test_prepare_anthropic_messages_tool_calls():
|
||||
assert blocks[1]["input"] == {"city": "Berlin"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _stream_full_google tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_text_only(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Google text chunks retain their content and finish normally."""
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[_google_stream_chunk(text="Hello"), _google_stream_chunk(text=" world")]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
messages = [Message(role=Role.USER, content="hi")]
|
||||
modules = _google_types_modules()
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in modules.items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(messages, model="gemini-2.5-flash")
|
||||
]
|
||||
|
||||
assert [chunk.content for chunk in result[:-1]] == ["Hello", " world"]
|
||||
assert result[-1].finish_reason == "stop"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_preserves_tool_calls(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Google function_call parts become OpenAI-compatible tool call chunks."""
|
||||
function_call = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
|
||||
part = SimpleNamespace(
|
||||
function_call=function_call, text=None, thought_signature=b"sig"
|
||||
)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[_google_stream_chunk(part)]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
messages = [Message(role=Role.USER, content="weather")]
|
||||
modules = _google_types_modules()
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in modules.items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
messages,
|
||||
model="gemini-2.5-flash",
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
tool_call = result[0].tool_calls[0]
|
||||
assert tool_call["index"] == 0
|
||||
assert tool_call["id"].startswith("google_")
|
||||
assert tool_call["type"] == "function"
|
||||
assert tool_call["function"] == {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Berlin"}',
|
||||
}
|
||||
assert tool_call["thought_signature"] == b"sig"
|
||||
assert engine._thought_sigs[tool_call["id"]] == b"sig"
|
||||
assert result[-1].finish_reason == "tool_calls"
|
||||
config = client.models.generate_content_stream.call_args.kwargs["config"]
|
||||
assert config.tools == [
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_preserves_mixed_and_multiple_calls(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Google streams retain mixed text and multiple tool calls."""
|
||||
weather = SimpleNamespace(name="get_weather", args={"city": "Berlin"})
|
||||
calendar = SimpleNamespace(name="get_calendar", args={"day": "Monday"})
|
||||
text_part = SimpleNamespace(text="I'll check.", function_call=None)
|
||||
weather_part = SimpleNamespace(
|
||||
function_call=weather, text=None, thought_signature=None
|
||||
)
|
||||
calendar_part = SimpleNamespace(
|
||||
function_call=calendar, text=None, thought_signature=None
|
||||
)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[
|
||||
_google_stream_chunk(text_part, weather_part),
|
||||
_google_stream_chunk(calendar_part),
|
||||
]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
modules = _google_types_modules()
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in modules.items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="plan")], model="gemini-2.5-flash"
|
||||
)
|
||||
]
|
||||
|
||||
assert result[0].content == "I'll check."
|
||||
weather_call = result[1].tool_calls[0]
|
||||
calendar_call = result[2].tool_calls[0]
|
||||
assert weather_call["index"] == 0
|
||||
assert weather_call["function"] == {
|
||||
"name": "get_weather",
|
||||
"arguments": '{"city": "Berlin"}',
|
||||
}
|
||||
assert calendar_call["index"] == 1
|
||||
assert calendar_call["function"] == {
|
||||
"name": "get_calendar",
|
||||
"arguments": '{"day": "Monday"}',
|
||||
}
|
||||
assert weather_call["id"] != calendar_call["id"]
|
||||
assert result[-1].finish_reason == "tool_calls"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_keeps_parallel_same_name_calls_distinct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Parallel invocations of one function receive unique indexes and IDs."""
|
||||
paris = SimpleNamespace(name="get_weather", args={"city": "Paris"})
|
||||
london = SimpleNamespace(name="get_weather", args={"city": "London"})
|
||||
parts = [
|
||||
SimpleNamespace(function_call=paris, text=None, thought_signature=b"sig"),
|
||||
SimpleNamespace(function_call=london, text=None, thought_signature=None),
|
||||
]
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[_google_stream_chunk(*parts)]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="Weather in Paris and London")],
|
||||
model="gemini-3-flash-preview",
|
||||
)
|
||||
]
|
||||
|
||||
calls = result[0].tool_calls
|
||||
assert [call["index"] for call in calls] == [0, 1]
|
||||
assert calls[0]["id"] != calls[1]["id"]
|
||||
assert [call["function"]["arguments"] for call in calls] == [
|
||||
'{"city": "Paris"}',
|
||||
'{"city": "London"}',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_ids_are_unique_across_requests(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Shared engines keep signatures isolated between conversations."""
|
||||
first_part = SimpleNamespace(
|
||||
function_call=SimpleNamespace(name="get_weather", args={"city": "Paris"}),
|
||||
text=None,
|
||||
thought_signature=b"paris-sig",
|
||||
)
|
||||
second_part = SimpleNamespace(
|
||||
function_call=SimpleNamespace(name="get_weather", args={"city": "London"}),
|
||||
text=None,
|
||||
thought_signature=b"london-sig",
|
||||
)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.side_effect = [
|
||||
iter([_google_stream_chunk(first_part)]),
|
||||
iter([_google_stream_chunk(second_part)]),
|
||||
]
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
first = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="Weather in Paris")],
|
||||
model="gemini-3-flash-preview",
|
||||
)
|
||||
]
|
||||
second = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="Weather in London")],
|
||||
model="gemini-3-flash-preview",
|
||||
)
|
||||
]
|
||||
|
||||
first_id = first[0].tool_calls[0]["id"]
|
||||
second_id = second[0].tool_calls[0]["id"]
|
||||
assert first_id != second_id
|
||||
assert engine._thought_sigs[first_id] == b"paris-sig"
|
||||
assert engine._thought_sigs[second_id] == b"london-sig"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_emits_final_usage(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Google's final usage metadata is normalized onto the terminal chunk."""
|
||||
usage = SimpleNamespace(prompt_token_count=12, candidates_token_count=5)
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter(
|
||||
[
|
||||
_google_stream_chunk(text="Hello"),
|
||||
_google_stream_chunk(usage_metadata=usage),
|
||||
]
|
||||
)
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {}
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
[Message(role=Role.USER, content="hi")],
|
||||
model="gemini-2.5-flash",
|
||||
)
|
||||
]
|
||||
|
||||
assert result[-1].finish_reason == "stop"
|
||||
assert result[-1].usage == {
|
||||
"prompt_tokens": 12,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 17,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_full_google_replays_signature_on_part(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""A saved Gemini signature is replayed beside, not inside, function_call."""
|
||||
client = MagicMock()
|
||||
client.models.generate_content_stream.return_value = iter([])
|
||||
engine = _make_cloud_engine(google_client=client)
|
||||
engine._thought_sigs = {"google_get_weather_0": b"sig"}
|
||||
messages = [
|
||||
Message(role=Role.USER, content="weather"),
|
||||
Message(
|
||||
role=Role.ASSISTANT,
|
||||
content=None,
|
||||
tool_calls=[
|
||||
ToolCall(
|
||||
id="google_get_weather_0",
|
||||
name="get_weather",
|
||||
arguments='{"city": "Berlin"}',
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(role=Role.TOOL, name="get_weather", content='{"temp": 20}'),
|
||||
]
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name, module in _google_types_modules().items():
|
||||
patch.setitem(sys.modules, name, module)
|
||||
result = [
|
||||
chunk
|
||||
async for chunk in engine.stream_full(
|
||||
messages, model="gemini-3-flash-preview"
|
||||
)
|
||||
]
|
||||
|
||||
contents = client.models.generate_content_stream.call_args.kwargs["contents"]
|
||||
assert contents[1]["parts"] == [
|
||||
{
|
||||
"function_call": {
|
||||
"name": "get_weather",
|
||||
"args": {"city": "Berlin"},
|
||||
},
|
||||
"thought_signature": b"sig",
|
||||
}
|
||||
]
|
||||
assert result[-1].finish_reason == "stop"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stream_full routing tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -265,6 +265,28 @@ class TestModelsEndpointExtended:
|
||||
assert "qwen3.5:9b" in ids
|
||||
assert "qwen3:0.6b" in ids
|
||||
|
||||
def test_models_list_filters_embedding_only_models(self):
|
||||
engine = _make_engine(
|
||||
models=["nomic-embed-text", "all-minilm:latest", "qwen3.5:4b"],
|
||||
)
|
||||
client = TestClient(create_app(engine, "qwen3.5:4b"))
|
||||
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert [m["id"] for m in resp.json()["data"]] == ["qwen3.5:4b"]
|
||||
|
||||
def test_models_list_returns_empty_when_only_embedders_are_installed(self):
|
||||
engine = _make_engine(
|
||||
models=["nomic-embed-text", "hf.co/BAAI/bge-m3:latest"],
|
||||
)
|
||||
client = TestClient(create_app(engine, "nomic-embed-text"))
|
||||
|
||||
resp = client.get("/v1/models")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
def test_models_empty_engine(self):
|
||||
"""When engine.list_models() returns empty, endpoint still succeeds."""
|
||||
engine = _make_engine(models=[])
|
||||
|
||||
@@ -50,3 +50,42 @@ def test_parse_param_count():
|
||||
assert _parse_param_count("qwen3.5:0.8b") == 0.8
|
||||
assert _parse_param_count("qwen3.5:35b") == 35.0
|
||||
assert _parse_param_count("gpt-4o") == 0.0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
def test_recommended_model_skips_embed_only():
|
||||
"""Embed-only models must never be recommended for chat."""
|
||||
from openjarvis.server.agent_manager_routes import _pick_recommended_model
|
||||
|
||||
models = [
|
||||
"nomic-embed-text",
|
||||
"qwen3.5:4b",
|
||||
"mxbai-embed-large",
|
||||
"qwen3.5:9b",
|
||||
]
|
||||
result = _pick_recommended_model(models)
|
||||
assert result["model"] == "qwen3.5:4b"
|
||||
assert "embed" not in result["model"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
def test_recommended_model_embed_only_returns_empty():
|
||||
"""If only embedders are installed, recommend nothing (not nomic-embed)."""
|
||||
from openjarvis.server.agent_manager_routes import _pick_recommended_model
|
||||
|
||||
result = _pick_recommended_model(["nomic-embed-text", "mxbai-embed-large"])
|
||||
assert result["model"] == ""
|
||||
assert "No local chat model" in result["reason"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
|
||||
def test_is_embed_only_model():
|
||||
from openjarvis.server.model_capabilities import is_embed_only_model
|
||||
|
||||
assert is_embed_only_model("nomic-embed-text")
|
||||
assert is_embed_only_model("mxbai-embed-large")
|
||||
assert is_embed_only_model("text-embedding-3-small")
|
||||
assert is_embed_only_model("all-minilm:latest")
|
||||
assert is_embed_only_model("hf.co/BAAI/bge-m3:latest")
|
||||
assert not is_embed_only_model("qwen3.5:4b")
|
||||
assert not is_embed_only_model("codegemma:7b")
|
||||
|
||||
Reference in New Issue
Block a user