diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
index eb252a7e..0519bf1f 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java
@@ -675,7 +675,10 @@ public class AgentGraphBuilder {
// RFC-062: Claude Code OAuth tunnels through the same Messages API
// wrapped in AnthropicChatModel — same StateGraph capability surface.
|| protocol == ModelProtocol.ANTHROPIC_CLAUDE_CODE
- || protocol == ModelProtocol.OPENAI_CHATGPT;
+ || protocol == ModelProtocol.OPENAI_CHATGPT
+ // Gemini native generateContent — GeminiChatModel exposes the same
+ // streaming + tool-calling surface the StateGraph nodes rely on.
+ || protocol == ModelProtocol.GEMINI_NATIVE;
}
// ==================== 模型构建 ====================
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/GeminiChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/GeminiChatModelBuilder.java
new file mode 100644
index 00000000..32fc52d9
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/GeminiChatModelBuilder.java
@@ -0,0 +1,51 @@
+package vip.mate.llm.chatmodel;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.retry.support.RetryTemplate;
+import org.springframework.stereotype.Component;
+import vip.mate.exception.MateClawException;
+import vip.mate.llm.gemini.GeminiChatModel;
+import vip.mate.llm.gemini.GeminiNativeClient;
+import vip.mate.llm.model.ModelConfigEntity;
+import vip.mate.llm.model.ModelProtocol;
+import vip.mate.llm.model.ModelProviderEntity;
+import vip.mate.llm.service.ModelProviderService;
+
+/**
+ * Strategy implementation for {@link ModelProtocol#GEMINI_NATIVE}.
+ *
+ *
Builds a {@link GeminiChatModel} over the native Gemini
+ * {@code generateContent} API. The {@link RetryTemplate} is ignored — the
+ * native client owns its own HTTP transport, mirroring the ChatGPT Responses
+ * builder.
+ */
+@Component
+@RequiredArgsConstructor
+public class GeminiChatModelBuilder implements ChatModelBuilder {
+
+ private final GeminiNativeClient geminiNativeClient;
+ private final ModelProviderService modelProviderService;
+
+ @Override
+ public ModelProtocol supportedProtocol() {
+ return ModelProtocol.GEMINI_NATIVE;
+ }
+
+ @Override
+ public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
+ if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
+ throw new MateClawException("err.agent.gemini_not_configured",
+ "Gemini Provider 未完成配置,请在模型设置中填写有效的 API Key");
+ }
+ String apiKey = provider.getApiKey();
+ if (!modelProviderService.hasUsableApiKey(apiKey)) {
+ throw new MateClawException("err.agent.gemini_key_invalid",
+ "Gemini API Key 未配置或无效: " + provider.getProviderId());
+ }
+ Double temperature = model.getTemperature();
+ Integer maxTokens = model.getMaxTokens();
+ return new GeminiChatModel(geminiNativeClient, provider.getBaseUrl(), apiKey.trim(),
+ model.getModelName(), temperature, maxTokens);
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/GeminiListModelsProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/GeminiListModelsProbe.java
new file mode 100644
index 00000000..635847eb
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/GeminiListModelsProbe.java
@@ -0,0 +1,96 @@
+package vip.mate.llm.failover.probe;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.HttpServerErrorException;
+import org.springframework.web.client.RestClient;
+import vip.mate.llm.failover.ProbeResult;
+import vip.mate.llm.failover.ProviderProbeStrategy;
+import vip.mate.llm.model.ModelProtocol;
+import vip.mate.llm.model.ModelProviderEntity;
+
+import java.net.http.HttpClient;
+import java.time.Duration;
+
+/**
+ * Probes a native Gemini provider by listing its models via
+ * {@code GET /v1beta/models?key=...}.
+ *
+ *
Auth failures (400 with an API-key error, 401, 403) are definitive
+ * negatives → HARD remove. Other 4xx/5xx are treated fail-open, since the
+ * chat path is the authoritative check.
+ */
+@Slf4j
+@Component
+public class GeminiListModelsProbe implements ProviderProbeStrategy {
+
+ private static final Duration TIMEOUT = Duration.ofSeconds(5);
+
+ @Override
+ public ModelProtocol supportedProtocol() {
+ return ModelProtocol.GEMINI_NATIVE;
+ }
+
+ @Override
+ public ProbeResult probe(ModelProviderEntity provider) {
+ if (provider == null || !StringUtils.hasText(provider.getApiKey())) {
+ return ProbeResult.fail(0, "API key not configured");
+ }
+ String baseUrl = provider.getBaseUrl();
+ if (!StringUtils.hasText(baseUrl)) {
+ baseUrl = "https://generativelanguage.googleapis.com";
+ }
+ baseUrl = stripTrailingSlash(baseUrl.trim());
+ long start = System.currentTimeMillis();
+ try {
+ HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
+ RestClient client = RestClient.builder()
+ .baseUrl(baseUrl)
+ .requestFactory(new JdkClientHttpRequestFactory(httpClient))
+ .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
+ .build();
+ client.get()
+ .uri("/v1beta/models?key={key}", provider.getApiKey().trim())
+ .retrieve()
+ .body(String.class);
+ return ProbeResult.ok(System.currentTimeMillis() - start);
+ } catch (HttpClientErrorException e) {
+ long latency = System.currentTimeMillis() - start;
+ int status = e.getStatusCode().value();
+ if (status == 401 || status == 403
+ || (status == 400 && e.getResponseBodyAsString().contains("API key"))) {
+ log.debug("[Probe] {} gemini models auth failed: {}", provider.getProviderId(), status);
+ return ProbeResult.fail(latency, "auth failed (" + status + ")");
+ }
+ log.info("[Probe] {} gemini models returned {} — fail-open (inconclusive)",
+ provider.getProviderId(), status);
+ return ProbeResult.ok(latency);
+ } catch (HttpServerErrorException e) {
+ long latency = System.currentTimeMillis() - start;
+ log.info("[Probe] {} gemini models returned {} — fail-open (5xx may be transient)",
+ provider.getProviderId(), e.getStatusCode().value());
+ return ProbeResult.ok(latency);
+ } catch (Exception e) {
+ long latency = System.currentTimeMillis() - start;
+ log.debug("[Probe] {} gemini models failed: {}", provider.getProviderId(), e.getMessage());
+ return ProbeResult.fail(latency, shortMessage(e));
+ }
+ }
+
+ private static String stripTrailingSlash(String url) {
+ return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
+ }
+
+ private static String shortMessage(Throwable t) {
+ String m = t.getMessage();
+ if (m == null) {
+ m = t.getClass().getSimpleName();
+ }
+ return m.length() > 200 ? m.substring(0, 200) + "..." : m;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/gemini/GeminiChatModel.java b/mateclaw-server/src/main/java/vip/mate/llm/gemini/GeminiChatModel.java
new file mode 100644
index 00000000..12a8a191
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/gemini/GeminiChatModel.java
@@ -0,0 +1,223 @@
+package vip.mate.llm.gemini;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
+import org.springframework.ai.chat.metadata.ChatResponseMetadata;
+import org.springframework.ai.chat.metadata.DefaultUsage;
+import org.springframework.ai.chat.metadata.Usage;
+import org.springframework.ai.chat.model.ChatModel;
+import org.springframework.ai.chat.model.ChatResponse;
+import org.springframework.ai.chat.model.Generation;
+import org.springframework.ai.chat.prompt.ChatOptions;
+import org.springframework.ai.chat.prompt.Prompt;
+import org.springframework.ai.model.tool.ToolCallingChatOptions;
+import org.springframework.ai.tool.ToolCallback;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import reactor.core.publisher.Flux;
+import vip.mate.llm.gemini.GeminiNativeClient.GeminiCall;
+import vip.mate.llm.gemini.GeminiNativeClient.StreamEvent;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Spring AI {@link ChatModel} backed by the native Gemini
+ * {@code generateContent} API. Extracts tool callbacks from the prompt
+ * options, delegates message translation + transport to
+ * {@link GeminiNativeClient}, and adapts Gemini's whole-at-once function calls
+ * into the start/args-delta {@link ChatResponse} shape the streaming agent
+ * pipeline expects.
+ */
+@Slf4j
+public class GeminiChatModel implements ChatModel {
+
+ private final GeminiNativeClient client;
+ private final String baseUrl;
+ private final String apiKey;
+ private final String modelName;
+ private final Double temperature;
+ private final Integer maxTokens;
+
+ public GeminiChatModel(GeminiNativeClient client, String baseUrl, String apiKey,
+ String modelName, Double temperature, Integer maxTokens) {
+ this.client = client;
+ this.baseUrl = baseUrl;
+ this.apiKey = apiKey;
+ this.modelName = modelName;
+ this.temperature = temperature;
+ this.maxTokens = maxTokens;
+ }
+
+ @Override
+ public ChatResponse call(Prompt prompt) {
+ GeminiCall call = buildCall(prompt);
+ JsonNode response = client.generate(call);
+
+ StringBuilder text = new StringBuilder();
+ List toolCalls = new ArrayList<>();
+ String finishReason = "stop";
+
+ for (JsonNode candidate : response.path("candidates")) {
+ String reason = candidate.path("finishReason").asText("");
+ if (!reason.isBlank()) {
+ finishReason = reason;
+ }
+ for (JsonNode part : candidate.path("content").path("parts")) {
+ if (part.path("thought").asBoolean(false)) {
+ continue;
+ }
+ JsonNode functionCall = part.get("functionCall");
+ if (functionCall != null && !functionCall.isNull()) {
+ String name = functionCall.path("name").asText("");
+ String id = functionCall.has("id")
+ ? functionCall.get("id").asText()
+ : "call_" + name + "_" + System.nanoTime();
+ String args = functionCall.has("args")
+ ? functionCall.get("args").toString() : "{}";
+ toolCalls.add(new AssistantMessage.ToolCall(id, "function", name, args));
+ continue;
+ }
+ JsonNode partText = part.get("text");
+ if (partText != null && partText.isTextual()) {
+ text.append(partText.asText());
+ }
+ }
+ }
+
+ AssistantMessage assistantMessage = toolCalls.isEmpty()
+ ? new AssistantMessage(text.toString())
+ : AssistantMessage.builder().content(text.toString()).toolCalls(toolCalls).build();
+ Generation generation = new Generation(assistantMessage,
+ ChatGenerationMetadata.builder().finishReason(finishReason).build());
+
+ ChatResponseMetadata.Builder metadata = ChatResponseMetadata.builder().model(call.model());
+ Usage usage = extractUsage(response.get("usageMetadata"));
+ if (usage != null) {
+ metadata.usage(usage);
+ }
+ return new ChatResponse(List.of(generation), metadata.build());
+ }
+
+ @Override
+ public Flux stream(Prompt prompt) {
+ GeminiCall call = buildCall(prompt);
+ return client.streamEvents(call)
+ .concatMapIterable(event -> toChatResponses(event, call.model()));
+ }
+
+ @Override
+ public ChatOptions getDefaultOptions() {
+ return ChatOptions.builder()
+ .model(modelName)
+ .temperature(temperature)
+ .build();
+ }
+
+ /**
+ * Adapt one {@link StreamEvent} into 0-2 {@link ChatResponse}s. A tool call
+ * becomes a start frame (id + name, empty args) followed by an args-delta
+ * frame (empty id, full args) so the streaming accumulator builds it the
+ * same way it does for OpenAI's incremental tool calls.
+ */
+ private List toChatResponses(StreamEvent event, String model) {
+ switch (event.type()) {
+ case "text" -> {
+ Generation gen = new Generation(new AssistantMessage(event.text()),
+ ChatGenerationMetadata.builder().finishReason(null).build());
+ return List.of(new ChatResponse(List.of(gen),
+ ChatResponseMetadata.builder().model(model).build()));
+ }
+ case "tool_call" -> {
+ AssistantMessage startMsg = AssistantMessage.builder()
+ .content("")
+ .toolCalls(List.of(new AssistantMessage.ToolCall(
+ event.toolCallId(), "function", event.toolName(), "")))
+ .build();
+ AssistantMessage deltaMsg = AssistantMessage.builder()
+ .content("")
+ .toolCalls(List.of(new AssistantMessage.ToolCall(
+ "", "function", "", event.toolArgs())))
+ .build();
+ ChatResponseMetadata md = ChatResponseMetadata.builder().model(model).build();
+ return List.of(
+ new ChatResponse(List.of(new Generation(startMsg,
+ ChatGenerationMetadata.builder().finishReason(null).build())), md),
+ new ChatResponse(List.of(new Generation(deltaMsg,
+ ChatGenerationMetadata.builder().finishReason(null).build())), md));
+ }
+ case "done" -> {
+ int in = event.inputTokens() != null ? event.inputTokens() : 0;
+ int out = event.outputTokens() != null ? event.outputTokens() : 0;
+ int total = event.totalTokens() != null ? event.totalTokens() : (in + out);
+ Usage usage = new DefaultUsage(in, out, total);
+ Generation gen = new Generation(new AssistantMessage(""),
+ ChatGenerationMetadata.builder().finishReason("stop").build());
+ return List.of(new ChatResponse(List.of(gen),
+ ChatResponseMetadata.builder().model(model).usage(usage).build()));
+ }
+ default -> {
+ return List.of();
+ }
+ }
+ }
+
+ private GeminiCall buildCall(Prompt prompt) {
+ List messages = prompt.getInstructions();
+ String model = resolveModel(prompt);
+ Double temp = resolveTemperature(prompt);
+ List tools = extractToolDefinitions(prompt);
+ return new GeminiCall(baseUrl, apiKey, model, messages, temp, maxTokens, tools);
+ }
+
+ private Usage extractUsage(JsonNode usageMetadata) {
+ if (usageMetadata == null || usageMetadata.isNull()) {
+ return null;
+ }
+ int in = usageMetadata.path("promptTokenCount").asInt(0);
+ int out = usageMetadata.path("candidatesTokenCount").asInt(0);
+ int total = usageMetadata.has("totalTokenCount")
+ ? usageMetadata.get("totalTokenCount").asInt() : in + out;
+ return new DefaultUsage(in, out, total);
+ }
+
+ private List extractToolDefinitions(Prompt prompt) {
+ ChatOptions options = prompt.getOptions();
+ if (options == null) {
+ return List.of();
+ }
+ List callbacks = null;
+ if (options instanceof ToolCallingChatOptions tcOpts) {
+ callbacks = tcOpts.getToolCallbacks();
+ } else {
+ try {
+ var method = options.getClass().getMethod("getToolCallbacks");
+ @SuppressWarnings("unchecked")
+ var result = (List) method.invoke(options);
+ callbacks = result;
+ } catch (Exception ignored) {
+ // options type carries no tool callbacks
+ }
+ }
+ if (callbacks == null || callbacks.isEmpty()) {
+ return List.of();
+ }
+ return callbacks.stream().map(ToolCallback::getToolDefinition).toList();
+ }
+
+ private String resolveModel(Prompt prompt) {
+ if (prompt.getOptions() != null && prompt.getOptions().getModel() != null) {
+ return prompt.getOptions().getModel();
+ }
+ return modelName;
+ }
+
+ private Double resolveTemperature(Prompt prompt) {
+ if (prompt.getOptions() != null && prompt.getOptions().getTemperature() != null) {
+ return prompt.getOptions().getTemperature();
+ }
+ return temperature;
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/llm/gemini/GeminiNativeClient.java b/mateclaw-server/src/main/java/vip/mate/llm/gemini/GeminiNativeClient.java
new file mode 100644
index 00000000..6d05607b
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/llm/gemini/GeminiNativeClient.java
@@ -0,0 +1,404 @@
+package vip.mate.llm.gemini;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.messages.AssistantMessage;
+import org.springframework.ai.chat.messages.Message;
+import org.springframework.ai.chat.messages.MessageType;
+import org.springframework.ai.chat.messages.ToolResponseMessage;
+import org.springframework.ai.chat.messages.UserMessage;
+import org.springframework.ai.content.Media;
+import org.springframework.ai.tool.definition.ToolDefinition;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+import reactor.core.publisher.Flux;
+import vip.mate.exception.MateClawException;
+
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.List;
+
+/**
+ * Native client for the Gemini {@code generateContent} API
+ * ({@code generativelanguage.googleapis.com}).
+ *
+ *
Translates Spring AI {@link Message} lists into Gemini {@code contents}
+ * (with {@code systemInstruction}, {@code functionCall} / {@code functionResponse}
+ * parts and inline image data), and parses the {@code streamGenerateContent}
+ * SSE stream back into {@link StreamEvent}s. Mirrors the role this project's
+ * {@code ChatGPTResponsesClient} plays for the OpenAI Responses API.
- * API: POST https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent
+ * Google Gemini native image provider — "Nano Banana".
+ *
+ *
Calls the Gemini {@code generateContent} endpoint with
+ * {@code responseModalities:[TEXT,IMAGE]} and returns the inline base64 image
+ * as a {@code data:} URI. Supports both text-to-image and image editing /
+ * image-to-image: reference images from {@link ImageGenerationRequest#getInputImages()}
+ * are sent as {@code inlineData} parts alongside the prompt.
+ *
+ *
Default model is Nano Banana Pro ({@code gemini-3-pro-image-preview}); the
+ * original Nano Banana ({@code gemini-2.5-flash-image}) is also available.
+ * Reuses the {@code gemini} LLM provider's API key — no separate credential.
+ *
+ *