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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ The format is based on Keep a Changelog.

## Unreleased

### Agents and API

- updated the Pi integration for the current `earendil-works/pi` packages and
release feed, registered mere.run through Pi's native dynamic-provider API,
and added a reusable package under `integrations/pi`.
- made `/v1/models` self-describing with additive task, tool-call, reasoning,
modality, context/output-limit, and OpenAI-dialect metadata. Pi now exposes
only models whose running API lane supports tools, maps model-specific
thinking levels, and honors Muse reasoning effort.
- moved served-model capabilities into typed managed-model catalog profiles.
API request validation, `/v1/models`, agent eligibility, and Pi's offline
fallback now consume the same source of truth, with runtime limit settings
applied only when describing the active server.

### Music

- accelerated MiniMax Music 3 on Apple Silicon with a reachable 16,385-row
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ swift run mere.run model capabilities --recommended
# Choose guided, bring-your-own-agent, or manual setup
swift run mere.run setup

# Or install Pi and launch it against a self-describing local provider
swift run mere.run agent install-pi
swift run mere.run agent start

# Pull a Hugging Face-backed model into the local model store
swift run mere.run model pull image-zimage-nano
swift run mere.run model pull image-zimage-nano --preflight --json
Expand Down Expand Up @@ -1015,6 +1019,11 @@ The public OSS build keeps local-first behavior by default and requires explicit
model
- `mere.run api serve` can bind to loopback without auth, but non-loopback hosts require `--api-key` or `MERERUN_API_KEY`
- the OpenAI-compatible chat and embedding routes require `Content-Type: application/json`, support `--rate-limit-per-minute` for basic abuse control, decode the common OpenAI request shapes, and reject unsupported high-impact fields before generation
- `/v1/models` adds task, tool-call, reasoning, modality, limit, and
compatibility metadata. The bundled Pi provider consumes these fields and
exposes only tool-capable chat models instead of guessing from model names.
Those fields project from typed managed-model catalog profiles; aliases and
active runtime limit overrides are layered on when the server responds.
- API LoRA adapters are operator-controlled with `--lora`; it accepts a
verified installed adapter catalog id or a local path, while per-request LoRA
paths are rejected
Expand Down
189 changes: 133 additions & 56 deletions Sources/MereRunCLI/Commands/APIServeCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -547,11 +547,27 @@ struct APIEngineCapabilities: Equatable, Sendable {
var supportsLogprobs: Bool = false
var supportsProviderThinkingControls: Bool = false

static let localText = APIEngineCapabilities()
static func catalog(_ profile: ManagedModelAPIProfile) -> APIEngineCapabilities {
APIEngineCapabilities(
supportsRawProxy: profile.supportsRawProxy,
supportsTools: profile.toolCall,
supportsToolChoice: profile.supportsToolChoice,
supportsDeveloperRole: profile.compatibility.supportsDeveloperRole,
supportsStructuredOutputs: profile.structuredOutput,
supportsReasoningEffort: profile.compatibility.supportsReasoningEffort,
supportsMaxCompletionTokens: profile.compatibility.maxTokensField == .maxCompletionTokens,
supportsUsageInStreaming: profile.compatibility.supportsUsageInStreaming,
supportsVisionContentParts: profile.inputModalities.contains(.image),
supportsStrictMode: profile.compatibility.supportsStrictMode,
supportsStopSequences: profile.supportsStopSequences,
supportsSeed: profile.supportsSeed,
supportsPenalties: profile.supportsPenalties,
supportsLogprobs: profile.supportsLogprobs,
supportsProviderThinkingControls: profile.supportsProviderThinkingControls
)
}

static let localTextWithStopSequences = APIEngineCapabilities(
supportsStopSequences: true
)
static let localText = APIEngineCapabilities()

static let localTextWithStructuredJSON = APIEngineCapabilities(
supportsStructuredOutputs: true
Expand All @@ -562,56 +578,11 @@ struct APIEngineCapabilities: Equatable, Sendable {
supportsToolChoice: true
)

static let localTextWithToolsAndStopSequences = APIEngineCapabilities(
supportsTools: true,
supportsToolChoice: true,
supportsStopSequences: true
)

static let localTextWithToolsAndStructuredJSON = APIEngineCapabilities(
supportsTools: true,
supportsToolChoice: true,
supportsStructuredOutputs: true
)

static let localTextWithToolsAndVision = APIEngineCapabilities(
supportsTools: true,
supportsToolChoice: true,
supportsVisionContentParts: true
)

static let localTextWithToolsVisionAndReasoning = APIEngineCapabilities(
supportsTools: true,
supportsToolChoice: true,
supportsReasoningEffort: true,
supportsVisionContentParts: true
)

static let localTextWithToolsVisionAndStructuredJSON = APIEngineCapabilities(
supportsTools: true,
supportsToolChoice: true,
supportsStructuredOutputs: true,
supportsVisionContentParts: true,
supportsStrictMode: false
)

static let rawProxy = APIEngineCapabilities(
supportsRawProxy: true,
supportsTools: true,
supportsToolChoice: true,
supportsDeveloperRole: true,
supportsStructuredOutputs: true,
supportsReasoningEffort: true,
supportsMaxCompletionTokens: true,
supportsUsageInStreaming: true,
supportsVisionContentParts: true,
supportsStrictMode: false,
supportsStopSequences: true,
supportsSeed: true,
supportsPenalties: true,
supportsLogprobs: true,
supportsProviderThinkingControls: true
)
}

struct APIHealthStatus: Codable, Equatable, Sendable {
Expand Down Expand Up @@ -1241,6 +1212,78 @@ enum APIServerContract {
)
}

static func chatModel(
id: String,
name: String,
profile: ManagedModelAPIProfile,
contextWindow: Int,
maximumOutputTokens: Int,
createdAt: Date = Date()
) -> OpenAIModel {
let compatibility = profile.compatibility
let thinkingLevels = profile.thinkingLevels.isEmpty
? nil
: profile.thinkingLevels.map(\.rawValue)
let thinkingLevelMap = profile.thinkingLevelMap.isEmpty
? nil
: Dictionary(uniqueKeysWithValues: profile.thinkingLevelMap.map {
($0.key.rawValue, $0.value.rawValue)
})

return OpenAIModel(
id: id,
object: "model",
created: Int(createdAt.timeIntervalSince1970),
owned_by: "mere.run",
name: name,
task: profile.task.rawValue,
reasoning: profile.reasoning,
thinking_levels: thinkingLevels,
tool_call: profile.toolCall,
structured_output: profile.structuredOutput,
modalities: OpenAIModelModalities(
input: profile.inputModalities.map(\.rawValue),
output: profile.outputModalities.map(\.rawValue)
),
limit: OpenAIModelLimit(context: contextWindow, output: maximumOutputTokens),
openai_compat: OpenAIModelCompatibility(
supports_store: compatibility.supportsStore,
supports_developer_role: compatibility.supportsDeveloperRole,
supports_reasoning_effort: compatibility.supportsReasoningEffort,
supports_usage_in_streaming: compatibility.supportsUsageInStreaming,
supports_finish_reason: compatibility.supportsFinishReason,
max_tokens_field: compatibility.maxTokensField.rawValue,
supports_strict_mode: compatibility.supportsStrictMode,
thinking_format: compatibility.thinkingFormat?.rawValue,
thinking_level_map: thinkingLevelMap,
requires_reasoning_content_on_assistant_messages: compatibility
.requiresReasoningContentOnAssistantMessages
)
)
}

static func companionModel(
id: String,
profile: ManagedModelAPIProfile,
createdAt: Date = Date()
) -> OpenAIModel {
return OpenAIModel(
id: id,
object: "model",
created: Int(createdAt.timeIntervalSince1970),
owned_by: "mere.run",
name: id,
task: profile.task.rawValue,
reasoning: profile.reasoning,
tool_call: profile.toolCall,
structured_output: profile.structuredOutput,
modalities: OpenAIModelModalities(
input: profile.inputModalities.map(\.rawValue),
output: profile.outputModalities.map(\.rawValue)
)
)
}

static func embeddingTexts(from request: OpenAIEmbeddingRequest) throws -> [String] {
guard !request.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw APIRequestValidationError.invalidField("model", "must not be empty")
Expand Down Expand Up @@ -2430,7 +2473,8 @@ enum APIServerContract {
fallbackLoraPath: String?,
contextSize: Int,
capabilities: APIEngineCapabilities = .localText,
servedModelID: String? = nil
servedModelID: String? = nil,
apiProfile: ManagedModelAPIProfile? = nil
) throws -> ChatRequest {
guard !openaiRequest.messages.isEmpty else {
throw APIRequestValidationError.invalidField("messages", "must contain at least one message")
Expand Down Expand Up @@ -2476,11 +2520,18 @@ enum APIServerContract {
// R1-style lanes degenerate without reasoning; their published top_k
// applies only when the client did not set explicit sampling.
let laneModelID = servedModelID ?? ""
let resolvedAPIProfile = apiProfile
?? servedModelID.flatMap { ManagedModelCatalog.apiProfile(for: $0) }
let recommendedSampling = Q35Resources.recommendedSampling(forModelId: laneModelID)
let isLaguna = LagunaResources.handles(modelSpec: laneModelID)
let usesExplicitSampling = openaiRequest.temperature != nil
|| openaiRequest.top_p != nil
|| openaiRequest.min_p != nil
let reasoningEffort = try reasoningEffort(
from: openaiRequest.reasoning_effort,
capabilities: capabilities,
profile: resolvedAPIProfile
)

return ChatRequest(
messages: messages,
Expand All @@ -2499,7 +2550,10 @@ enum APIServerContract {
minP: openaiRequest.min_p == nil && isLaguna
? LagunaResources.recommendedMinP
: minP,
showThinking: requiresJSON ? false : Q35Resources.thinkingDefault(forModelId: laneModelID),
reasoningEffort: reasoningEffort,
showThinking: requiresJSON
? false
: resolvedAPIProfile?.thinkingLevels == [.high],
lora: lora,
requiresJSON: requiresJSON,
tools: tools,
Expand Down Expand Up @@ -2802,6 +2856,28 @@ enum APIServerContract {
return try validateMaxTokens(maxCompletionTokens ?? maxTokens, contextSize: contextSize)
}

private static func reasoningEffort(
from rawValue: String?,
capabilities: APIEngineCapabilities,
profile: ManagedModelAPIProfile?
) throws -> Double? {
guard let rawValue else { return nil }
guard !capabilities.supportsRawProxy else { return nil }
let normalized = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard let level = ManagedModelThinkingLevel(rawValue: normalized),
let strength = profile?.reasoningEffortStrengths[level] else {
let supportedLevels = ManagedModelThinkingLevel.allCases
.filter { profile?.reasoningEffortStrengths[$0] != nil }
.map(\.rawValue)
.joined(separator: ", ")
throw APIRequestValidationError.invalidField(
"reasoning_effort",
"must be one of \(supportedLevels)"
)
}
return strength
}

static func acceptsJSONContentType(_ rawValue: String?) -> Bool {
guard let mediaType = rawValue?
.split(separator: ";", maxSplits: 1, omittingEmptySubsequences: true)
Expand Down Expand Up @@ -3810,20 +3886,21 @@ actor CodeGenServer {
let includeLoopbackArtifactModels = APIVFXArtifactRoutePolicy.allows(
remoteAddress: remoteAddress
)
var models = try await pool.modelsResponse()
var models = try await pool.modelsResponse(serverContextSize: contextSize)
if !includeLoopbackArtifactModels {
models.data.removeAll { APIVFXArtifactRoutePolicy.modelIDs.contains($0.id) }
}
for modelID in APIServerContract.companionModelIDs(
includeLoopbackArtifactModels: includeLoopbackArtifactModels
)
where !models.data.contains(where: { $0.id == modelID }) {
guard let profile = ManagedModelCatalog.apiProfile(for: modelID) else {
continue
}
models.data.append(
OpenAIModel(
APIServerContract.companionModel(
id: modelID,
object: "model",
created: Int(Date().timeIntervalSince1970),
owned_by: "mere.run"
profile: profile
)
)
}
Expand Down
11 changes: 8 additions & 3 deletions Sources/MereRunCLI/Commands/AgentCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,13 +240,13 @@ struct AgentOnboard: AsyncParsableCommand {
}

let startable = MereRunAgentModelCatalog.fallbackStartableRecommendation(on: machine)
let selectedAgentID = startable?.id ?? AgentModelResources.qwen35NineBModelId
let selectedAgentID = startable?.id ?? Q35Resources.ornith9BModelId
print("\nAgent readiness")
if let startable {
print(" Recommended setup agent: \(startable.id) (\(startable.displayName)).")
print(" Start a guided session with: \(CLICommandDisplay.command("agent start --model \(selectedAgentID)"))")
if startable.id == DeepseekV4FlashResources.defaultModelId {
print(" DeepSeek V4 Flash is the preferred 96 GB+ setup-agent tier; smaller Qwen agents are alternatives, not upgrades.")
print(" DeepSeek V4 Flash is the preferred 96 GB+ setup-agent tier; smaller tool-capable native agents are alternatives, not upgrades.")
}
}
if let codeReport = reports.first(where: { $0.spec.id == CodeGenResources.defaultModelId }) {
Expand Down Expand Up @@ -362,7 +362,12 @@ struct AgentOnboard: AsyncParsableCommand {
guard let recommendation = SetupAgentRuntime.recommendation(forManagedModelID: normalized) else {
throw ValidationError("Unsupported Pi provider model: \(modelID)")
}
return SetupAgentRuntime.providerModel(for: recommendation)
guard recommendation.isStartableByMereRun else {
throw ValidationError(
"Pi requires a tool-capable chat model; \(modelID) is only available through the text-code API lane."
)
}
return try SetupAgentRuntime.providerModel(for: recommendation)
}
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/MereRunCLI/Commands/ModelCapabilitiesCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ struct ModelCapabilities: ParsableCommand {
print(" \(CLICommandDisplay.command("agent start --model \(agent.id)"))")
print(" \(agent.displayName): \(agent.summary)")
if agent.id == DeepseekV4FlashResources.defaultModelId {
print(" note: DeepSeek V4 Flash is the preferred 96 GB+ setup-agent tier; smaller Qwen agents are lower-memory alternatives, not upgrades.")
print(" note: DeepSeek V4 Flash is the preferred 96 GB+ setup-agent tier; smaller tool-capable native agents are lower-memory alternatives, not upgrades.")
}
}

Expand Down
Loading
Loading