Skip to content
Draft
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
22 changes: 18 additions & 4 deletions .cursor/rules/swift-build.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,26 @@ alwaysApply: false

# Building OsaurusCore

The xcode workspace has pre-existing build failures in external dependencies (`mlx-swift-lm`, `IkigaJSON`). Never use `xcodebuild` to verify changes — it will always fail on those deps and waste tokens.
Use focused package tests while iterating, and use CI-parity `xcodebuild` only when you need to reproduce the GitHub Actions `test-core` job.

Instead, compile only the OsaurusCore package sources (no linking) to verify your changes:
Fast local checks from the repository root:

```bash
cd /Users/tpae/dev/osaurus/Packages/OsaurusCore && swift build 2>&1 | grep -E "error:" | grep -v "IkigaJSON"
swift test --package-path Packages/OsaurusCore
swift test --package-path Packages/OsaurusCLI --parallel
swift-format lint --strict --recursive Packages App
```

If the filtered output is empty, your code compiles cleanly.
CI-parity check from the repository root:

```bash
make ci-test
```

If you only need a compile smoke test for core sources, this is acceptable:

```bash
swift build --package-path Packages/OsaurusCore
```

Do not hardcode local absolute paths in docs or scripts. Use repo-root-relative commands unless a tool explicitly requires an absolute path.
4 changes: 2 additions & 2 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ If UI updated, add before/after.

## Checklist

- [ ] I have read `CONTRIBUTING.md`
- [ ] I have read `docs/CONTRIBUTING.md`
- [ ] I added/updated tests where reasonable
- [ ] I updated docs/README as needed
- [ ] I verified build on macOS with Xcode 16.4+
- [ ] I verified build on macOS with a Swift 6.2-capable Xcode toolchain
36 changes: 20 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ permissions:
env:
# Bump to invalidate every cache entry without source surgery (e.g., after a
# known-bad cache or an Xcode toolchain upgrade we want to flush manually).
CACHE_SALT: v2-vmlx-5b84387
CACHE_SALT: v3-pr-cold-deriveddata
# Pin Xcode so cache keys are stable across runner image bumps. When you
# need to upgrade, change here AND in setup-xcode below.
XCODE_VERSION: "26.4.1"
Expand Down Expand Up @@ -83,9 +83,11 @@ jobs:

- name: Restore DerivedData cache
id: dd-cache
# Always restore so `cache-primary-key` is populated for the save
# step at the bottom (the wipe step below handles forced cold
# builds without preventing main from repopulating the cache).
# Restore only on main pushes / manual maintainer runs. Pull requests
# intentionally cold-build DerivedData: exact restore-key hits have
# still produced stale Swift modules whose C-module dependencies are
# missing when Xcode later compiles EventSource.
if: ${{ github.event_name != 'pull_request' }}
uses: actions/cache/restore@v5
with:
path: ~/Library/Developer/Xcode/DerivedData
Expand All @@ -97,13 +99,15 @@ jobs:
restore-keys: |
dd-${{ runner.os }}-${{ env.CACHE_SALT }}-xcode${{ env.XCODE_VERSION }}-

# Make "clear the build cache" a one-click operation. Two triggers:
# 1. `github.run_attempt != '1'` — i.e. a re-run. The default
# Make "clear the build cache" a one-click operation. Three triggers:
# 1. Pull requests — always cold-build DerivedData so PRs never trust
# a cached Xcode build product from another ref.
# 2. `github.run_attempt != '1'` — i.e. a re-run. The default
# "Re-run failed jobs" button is the natural place for someone
# who just saw a build failure to land, so we make that the
# intuitive escape hatch for cache poison: the first attempt
# uses the cache (fast); any re-run forces a cold compile.
# 2. `workflow_dispatch.clear_cache=true` — manual force-cold on
# 3. `workflow_dispatch.clear_cache=true` — manual force-cold on
# a fresh run (e.g. validating a CACHE_SALT bump before PRs
# start hitting it).
#
Expand All @@ -116,18 +120,18 @@ jobs:
# every re-run cost ~2 min in PR #951 run 24937664669 — wasted
# budget that contributed to the 30-min cold-build cancellation.
#
# We wipe AFTER the restore step (rather than skipping the restore)
# so `steps.dd-cache.outputs.cache-primary-key` stays populated and
# the `Save DerivedData cache` step at the bottom can still
# repopulate the cache on a successful `main` run.
- name: Wipe restored DerivedData (re-run or workflow_dispatch clear_cache)
if: ${{ github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }}
# On main/manual runs we wipe AFTER the restore step (rather than
# skipping the restore) so `steps.dd-cache.outputs.cache-primary-key`
# stays populated and the `Save DerivedData cache` step at the bottom
# can still repopulate the cache on a successful `main` run.
- name: Wipe restored DerivedData (PR, re-run, or workflow_dispatch clear_cache)
if: ${{ github.event_name == 'pull_request' || github.run_attempt != '1' || (github.event_name == 'workflow_dispatch' && inputs.clear_cache) }}
run: |
REASON="run_attempt=${{ github.run_attempt }}"
REASON="event=${{ github.event_name }}, run_attempt=${{ github.run_attempt }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.clear_cache }}" = "true" ]; then
REASON="$REASON, workflow_dispatch clear_cache=true"
fi
echo "::notice title=Cold build forced::Wiping restored DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved). To re-run with the warm cache instead, push a new commit or trigger a fresh run."
echo "::notice title=Cold build forced::Wiping DerivedData before build ($REASON). SPM cache preserved (it's source-only and pinned by Package.resolved)."
rm -rf "$HOME/Library/Developer/Xcode/DerivedData"

- name: Resolve dependencies
Expand Down Expand Up @@ -248,7 +252,7 @@ jobs:
echo
echo "**\`run_attempt > 1\` AND \`cache-hit: false\`?** That's the deliberate cold-rebuild path triggered by **Re-run failed jobs** — see the \`Wipe restored DerivedData\` step in this job. If the cold build is exhausting the 45-min budget on every re-run, the codebase has outgrown the budget; bump \`timeout-minutes\` and update its comment block, OR move warm-cache priming to a nightly \`main\` job so PRs always warm-start."
echo
echo "**Suspect cache poisoning on a fresh attempt?** Click **Re-run failed jobs** — re-runs automatically wipe DerivedData (the SPM cache is preserved because it's pinned by \`Package.resolved\` and can't be poisoned)."
echo "**Suspect cache poisoning on a fresh attempt?** Pull requests already cold-build DerivedData; main/manual re-runs wipe DerivedData automatically while preserving the pinned SPM source cache."
} >> "$GITHUB_STEP_SUMMARY"
else
# Mode B.
Expand Down
38 changes: 27 additions & 11 deletions Packages/OsaurusCore/Models/API/OpenAIAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ struct ChatMessage: Codable, Sendable {
let tool_calls: [ToolCall]?
/// Required for role=="tool" messages to associate with a prior tool call
let tool_call_id: String?
/// Provider-specific reasoning text that some thinking APIs require on
/// follow-up requests after an assistant tool-call turn.
let reasoning_content: String?

/// Extract image URLs from content parts (supports both data URLs and http URLs)
var imageUrls: [String] {
Expand Down Expand Up @@ -306,13 +309,15 @@ extension ChatMessage {
case content
case tool_calls
case tool_call_id
case reasoning_content
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.role = try container.decode(String.self, forKey: .role)
self.tool_calls = try? container.decode([ToolCall].self, forKey: .tool_calls)
self.tool_call_id = try? container.decode(String.self, forKey: .tool_call_id)
self.reasoning_content = try? container.decode(String.self, forKey: .reasoning_content)

if let stringContent = try? container.decode(String.self, forKey: .content) {
self.content = stringContent
Expand Down Expand Up @@ -357,6 +362,7 @@ extension ChatMessage {
// Note: content is intentionally omitted when nil (e.g., assistant messages with tool_calls)
try container.encodeIfPresent(tool_calls, forKey: .tool_calls)
try container.encodeIfPresent(tool_call_id, forKey: .tool_call_id)
try container.encodeIfPresent(reasoning_content, forKey: .reasoning_content)
}
}

Expand All @@ -367,15 +373,23 @@ extension ChatMessage {
self.contentParts = nil
self.tool_calls = nil
self.tool_call_id = nil
self.reasoning_content = nil
}

/// Initialize with optional tool calls and tool call id
init(role: String, content: String?, tool_calls: [ToolCall]?, tool_call_id: String?) {
init(
role: String,
content: String?,
tool_calls: [ToolCall]?,
tool_call_id: String?,
reasoning_content: String? = nil
) {
self.role = role
self.content = content
self.contentParts = nil
self.tool_calls = tool_calls
self.tool_call_id = tool_call_id
self.reasoning_content = reasoning_content
}

/// Initialize with multimodal content (text and images)
Expand All @@ -399,6 +413,7 @@ extension ChatMessage {
self.content = text.isEmpty ? nil : text
self.tool_calls = nil
self.tool_call_id = nil
self.reasoning_content = nil
}

/// Multimodal init covering image + audio + video. Used by the
Expand Down Expand Up @@ -450,6 +465,7 @@ extension ChatMessage {
self.content = text.isEmpty ? nil : text
self.tool_calls = nil
self.tool_call_id = nil
self.reasoning_content = nil
}
}

Expand Down Expand Up @@ -824,26 +840,26 @@ public enum JSONValue: Codable, Sendable, Equatable {

extension JSONValue {
/// Convert JSONValue to Sendable-compatible value for Jinja chat templates.
/// Null values are dropped from dictionaries because Jinja's `Value(any:)` cannot
/// handle `NSNull` and throws a runtime error. JSON Schema treats a missing key
/// the same as `null`, so this is semantically lossless for tool specs.
var sendableValue: any Sendable {
/// Null values are dropped because Jinja's `Value(any:)` cannot handle
/// null/optional placeholders inside erased Swift containers.
var sendableValue: (any Sendable)? {
switch self {
case .null:
return NSNull()
return nil
case .bool(let b):
return b
case .number(let n):
return n
case .string(let s):
return s
case .array(let arr):
return arr.map { $0.sendableValue }
return arr.compactMap { $0.sendableValue }
case .object(let obj):
var dict: [String: any Sendable] = [:]
for (k, v) in obj {
if case .null = v { continue }
dict[k] = v.sendableValue
if let converted = v.sendableValue {
dict[k] = converted
}
}
return dict
}
Expand Down Expand Up @@ -881,8 +897,8 @@ extension ToolFunction {
if let description {
fn["description"] = description
}
if let parameters {
fn["parameters"] = parameters.sendableValue
if let parameters, let converted = parameters.sendableValue {
fn["parameters"] = converted
}
return fn
}
Expand Down
5 changes: 5 additions & 0 deletions Packages/OsaurusCore/Models/Chat/ChatSessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ enum ChatSessionStore {
print("[ChatSessionStore] Failed to open chat-history database: \(error)")
return
}
#if DEBUG
if RuntimeEnvironment.isUnderTests, OsaurusPaths.overrideRoot == nil {
return
}
#endif
LegacySessionImporter.runIfNeeded()
}
}
3 changes: 2 additions & 1 deletion Packages/OsaurusCore/Services/Chat/ChatEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ actor ChatEngine: Sendable, ChatEngineProtocol {
role: "assistant",
content: nil,
tool_calls: toolCalls,
tool_call_id: nil
tool_call_id: nil,
reasoning_content: invocations.compactMap(\.reasoningContent).first
)
let choice = ChatChoice(index: 0, message: assistant, finish_reason: "tool_calls")
let usage = Usage(prompt_tokens: inputTokens, completion_tokens: 0, total_tokens: inputTokens)
Expand Down
12 changes: 11 additions & 1 deletion Packages/OsaurusCore/Services/Inference/ModelService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,22 @@ struct ServiceToolInvocation: Error, Sendable {
let toolCallId: String?
/// Optional thought signature for Gemini thinking-mode models (e.g. Gemini 2.5)
let geminiThoughtSignature: String?
/// Provider reasoning text that must be echoed on assistant tool-call
/// messages for APIs such as DeepSeek thinking mode.
let reasoningContent: String?

init(toolName: String, jsonArguments: String, toolCallId: String? = nil, geminiThoughtSignature: String? = nil) {
init(
toolName: String,
jsonArguments: String,
toolCallId: String? = nil,
geminiThoughtSignature: String? = nil,
reasoningContent: String? = nil
) {
self.toolName = toolName
self.jsonArguments = jsonArguments
self.toolCallId = toolCallId
self.geminiThoughtSignature = geminiThoughtSignature
self.reasoningContent = reasoningContent
}
}

Expand Down
Loading
Loading